v1.3.4
This commit is contained in:
commit
228889f850
15 changed files with 109 additions and 164 deletions
97
.drone.yml
97
.drone.yml
|
|
@ -144,7 +144,7 @@ steps:
|
|||
# Services for Unit Test Backend
|
||||
services:
|
||||
- name: rabbit_test
|
||||
image: rabbitmq:3.7.19
|
||||
image: rabbitmq:3.12.0
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: rabbitmq
|
||||
RABBITMQ_DEFAULT_PASS: rabbitmq
|
||||
|
|
@ -224,10 +224,8 @@ trigger:
|
|||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: OSS engine release (amd64)
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
name: OSS engine release
|
||||
|
||||
steps:
|
||||
- name: set engine version
|
||||
image: alpine
|
||||
|
|
@ -244,7 +242,8 @@ steps:
|
|||
DOCKER_BUILDKIT: 1
|
||||
settings:
|
||||
repo: grafana/oncall
|
||||
tags: ${DRONE_TAG}-amd64-linux
|
||||
tags: ${DRONE_TAG}
|
||||
platform: linux/arm64/v8,linux/amd64
|
||||
dockerfile: engine/Dockerfile
|
||||
target: prod
|
||||
context: engine/
|
||||
|
|
@ -263,90 +262,6 @@ trigger:
|
|||
ref:
|
||||
- refs/tags/v*.*.*
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: OSS engine release (arm64)
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
steps:
|
||||
- name: set engine version
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache bash sed
|
||||
- if [ -z "$DRONE_TAG" ]; then echo "No tag, not modifying version"; else sed "0,/VERSION.*/ s/VERSION.*/VERSION = \"${DRONE_TAG}\"/g" engine/settings/base.py > engine/settings/base.temp && mv engine/settings/base.temp engine/settings/base.py; fi
|
||||
- cat engine/settings/base.py | grep VERSION | head -1
|
||||
|
||||
- name: build and push docker image
|
||||
image: plugins/docker
|
||||
environment:
|
||||
# force docker to use buildkit feature, this will skip build stages that aren't required in the final image (ie. dev & dev-enterprise)
|
||||
# https://github.com/docker/cli/issues/1134#issuecomment-406449342
|
||||
DOCKER_BUILDKIT: 1
|
||||
settings:
|
||||
repo: grafana/oncall
|
||||
tags: ${DRONE_TAG}-arm64-linux
|
||||
dockerfile: engine/Dockerfile
|
||||
target: prod
|
||||
context: engine/
|
||||
password:
|
||||
from_secret: docker_password
|
||||
username:
|
||||
from_secret: docker_username
|
||||
depends_on:
|
||||
- set engine version
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
target:
|
||||
- oss
|
||||
ref:
|
||||
- refs/tags/v*.*.*
|
||||
|
||||
---
|
||||
depends_on:
|
||||
- OSS engine release (amd64)
|
||||
- OSS engine release (arm64)
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: manifest
|
||||
steps:
|
||||
- name: manifest tag
|
||||
image: plugins/manifest
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
target: "grafana/oncall:${DRONE_TAG}"
|
||||
template: "grafana/oncall:${DRONE_TAG}-ARCH-OS"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
|
||||
- name: manifest latest
|
||||
image: plugins/manifest
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
target: "grafana/oncall:latest"
|
||||
template: "grafana/oncall:${DRONE_TAG}-ARCH-OS"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
target:
|
||||
- oss
|
||||
ref:
|
||||
- refs/tags/v*.*.*
|
||||
|
||||
---
|
||||
# Secret for pulling docker images.
|
||||
kind: secret
|
||||
|
|
@ -418,6 +333,6 @@ kind: secret
|
|||
name: drone_token
|
||||
---
|
||||
kind: signature
|
||||
hmac: 59ad498428731001484be9ac6b9d10685b2cd49f30eb3d97ed7dc99f031dca22
|
||||
hmac: 610fb051f4361baa2621f6aa41c18b3afee5160492b30f5a5f23fcecf86f1b0c
|
||||
|
||||
...
|
||||
|
|
|
|||
6
.github/workflows/linting-and-tests.yml
vendored
6
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -96,7 +96,7 @@ jobs:
|
|||
SLACK_CLIENT_OAUTH_ID: 1
|
||||
services:
|
||||
rabbit_test:
|
||||
image: rabbitmq:3.7.19
|
||||
image: rabbitmq:3.12.0
|
||||
env:
|
||||
RABBITMQ_DEFAULT_USER: rabbitmq
|
||||
RABBITMQ_DEFAULT_PASS: rabbitmq
|
||||
|
|
@ -148,7 +148,7 @@ jobs:
|
|||
ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }}
|
||||
services:
|
||||
rabbit_test:
|
||||
image: rabbitmq:3.7.19
|
||||
image: rabbitmq:3.12.0
|
||||
env:
|
||||
RABBITMQ_DEFAULT_USER: rabbitmq
|
||||
RABBITMQ_DEFAULT_PASS: rabbitmq
|
||||
|
|
@ -192,7 +192,7 @@ jobs:
|
|||
ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }}
|
||||
services:
|
||||
rabbit_test:
|
||||
image: rabbitmq:3.7.19
|
||||
image: rabbitmq:3.12.0
|
||||
env:
|
||||
RABBITMQ_DEFAULT_USER: rabbitmq
|
||||
RABBITMQ_DEFAULT_PASS: rabbitmq
|
||||
|
|
|
|||
|
|
@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## v1.3.3
|
||||
|
||||
## v1.3.4 (2023-06-29)
|
||||
|
||||
This version contains just some small cleanup and CI changes 🙂
|
||||
|
||||
## v1.3.3 (2023-06-28)
|
||||
|
||||
### Added
|
||||
|
||||
- Add metric "how many alert groups user was notified of" to Prometheus exporter ([#2334](https://github.com/grafana/oncall/pull/2334/))
|
||||
|
||||
## v1.3.2
|
||||
## v1.3.2 (2023-06-28)
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ services:
|
|||
rabbitmq:
|
||||
container_name: rabbitmq
|
||||
labels: *oncall-labels
|
||||
image: "rabbitmq:3.7.15-management"
|
||||
image: "rabbitmq:3.12.0-management"
|
||||
restart: always
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "rabbitmq"
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ services:
|
|||
cpus: "0.1"
|
||||
|
||||
rabbitmq:
|
||||
image: "rabbitmq:3.7.15-management"
|
||||
image: "rabbitmq:3.12.0-management"
|
||||
restart: always
|
||||
hostname: rabbitmq
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.19 on 2023-06-28 16:00
|
||||
|
||||
from django.db import migrations
|
||||
import django_migration_linter as linter
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0017_alertgroup_is_restricted'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
linter.IgnoreMigration(), # This field is deprecated a long time ago.
|
||||
migrations.RemoveField(
|
||||
model_name='alertreceivechannel',
|
||||
name='integration_slack_channel_id',
|
||||
),
|
||||
]
|
||||
|
|
@ -160,8 +160,6 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
verbal_name = models.CharField(max_length=150, null=True, default=None)
|
||||
description_short = models.CharField(max_length=250, null=True, default=None)
|
||||
|
||||
integration_slack_channel_id = models.CharField(max_length=150, null=True, default=None)
|
||||
|
||||
is_finished_alerting_setup = models.BooleanField(default=False)
|
||||
|
||||
# *_*_template fields are legacy way of storing templates
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ def test_render_for_phone_call(
|
|||
make_alert,
|
||||
):
|
||||
organization, _ = make_organization_with_slack_team_identity()
|
||||
alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD")
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
SlackMessage.objects.create(channel_id="CWER1ASD", alert_group=alert_group)
|
||||
|
|
@ -60,7 +60,7 @@ def test_delete(
|
|||
slack_channel = make_slack_channel(slack_team_identity, name="general", slack_id="CWER1ASD")
|
||||
user = make_user(organization=organization)
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD")
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
SlackMessage.objects.create(channel_id="CWER1ASD", alert_group=alert_group)
|
||||
|
|
@ -104,7 +104,7 @@ def test_alerts_count_gt(
|
|||
make_alert,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD")
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
|
|
|
|||
|
|
@ -69,9 +69,9 @@ class OrderedModel(models.Model):
|
|||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if self.order is None:
|
||||
self._save_no_order_provided()
|
||||
self._save_no_order_provided(*args, **kwargs)
|
||||
else:
|
||||
super().save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@_retry(OperationalError) # retry on deadlock
|
||||
def delete(self, *args, **kwargs) -> None:
|
||||
|
|
@ -81,7 +81,7 @@ class OrderedModel(models.Model):
|
|||
super().delete(*args, **kwargs)
|
||||
|
||||
@_retry((IntegrityError, OperationalError)) # retry on duplicate order or deadlock
|
||||
def _save_no_order_provided(self) -> None:
|
||||
def _save_no_order_provided(self, *args, **kwargs) -> None:
|
||||
"""
|
||||
Save self to DB without an order provided (e.g on creation).
|
||||
Order is set to the next available order, or 0 if there are no other instances.
|
||||
|
|
@ -97,7 +97,7 @@ class OrderedModel(models.Model):
|
|||
instances = self._lock_ordering_queryset() # lock ordering queryset to prevent reading inconsistent data
|
||||
max_order = max(instance.order for instance in instances) if instances else -1
|
||||
self.order = max_order + 1
|
||||
super().save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@_retry(OperationalError) # retry on deadlock
|
||||
def to(self, order: int) -> None:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ EVENT_SUBTYPE_MESSAGE_CHANGED = "message_changed"
|
|||
EVENT_SUBTYPE_MESSAGE_DELETED = "message_deleted"
|
||||
EVENT_SUBTYPE_BOT_MESSAGE = "bot_message"
|
||||
EVENT_SUBTYPE_THREAD_BROADCAST = "thread_broadcast"
|
||||
EVENT_SUBTYPE_FILE_SHARE = "file_share"
|
||||
EVENT_TYPE_CHANNEL_DELETED = "channel_deleted"
|
||||
EVENT_TYPE_CHANNEL_CREATED = "channel_created"
|
||||
EVENT_TYPE_CHANNEL_RENAMED = "channel_rename"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from apps.integrations.tasks import create_alert
|
||||
from apps.slack.scenarios import scenario_step
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -30,9 +28,6 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep):
|
|||
and "thread_ts" in payload["event"]["previous_message"]
|
||||
):
|
||||
self.delete_thread_message_from_resolution_note(slack_user_identity, payload)
|
||||
# Otherwise check if it is a message from channel with Slack Channel Integration
|
||||
else:
|
||||
self.create_alert_for_slack_channel_integration_if_needed(payload)
|
||||
|
||||
def save_thread_message_for_resolution_note(self, slack_user_identity, payload):
|
||||
ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage")
|
||||
|
|
@ -144,37 +139,6 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep):
|
|||
slack_thread_message.delete()
|
||||
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
|
||||
|
||||
def create_alert_for_slack_channel_integration_if_needed(self, payload):
|
||||
if "subtype" in payload["event"] and payload["event"]["subtype"] != scenario_step.EVENT_SUBTYPE_FILE_SHARE:
|
||||
return
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
alert_receive_channels = AlertReceiveChannel.objects.filter(
|
||||
integration_slack_channel_id=payload["event"]["channel"], organization=self.organization
|
||||
).all()
|
||||
for alert_receive_channel in alert_receive_channels:
|
||||
r = self._slack_client.api_call(
|
||||
"chat.getPermalink",
|
||||
channel=payload["event"]["channel"],
|
||||
message_ts=payload["event"]["ts"],
|
||||
)
|
||||
# insert permalink to payload to have access to it in templaters
|
||||
payload["event"]["amixr_mixin"] = {"permalink": r["permalink"]}
|
||||
|
||||
create_alert.apply_async(
|
||||
[],
|
||||
{
|
||||
"title": "<#{}>".format(payload["event"]["channel"]),
|
||||
"message": "{}\n_New message in <#{}> channel_".format(
|
||||
payload["event"]["text"], payload["event"]["channel"]
|
||||
),
|
||||
"image_url": None,
|
||||
"link_to_upstream_details": r["permalink"],
|
||||
"alert_receive_channel_pk": alert_receive_channel.pk,
|
||||
"integration_unique_data": json.dumps(payload["event"]),
|
||||
"raw_request_data": payload["event"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
STEPS_ROUTING = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_
|
|||
from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING
|
||||
from apps.slack.scenarios.scenario_step import (
|
||||
EVENT_SUBTYPE_BOT_MESSAGE,
|
||||
EVENT_SUBTYPE_FILE_SHARE,
|
||||
EVENT_SUBTYPE_MESSAGE_CHANGED,
|
||||
EVENT_SUBTYPE_MESSAGE_DELETED,
|
||||
EVENT_TYPE_APP_MENTION,
|
||||
|
|
@ -334,7 +333,6 @@ class SlackEventApiEndpointView(APIView):
|
|||
in [
|
||||
EVENT_SUBTYPE_BOT_MESSAGE,
|
||||
EVENT_SUBTYPE_MESSAGE_CHANGED,
|
||||
EVENT_SUBTYPE_FILE_SHARE,
|
||||
EVENT_SUBTYPE_MESSAGE_DELETED,
|
||||
]
|
||||
)
|
||||
|
|
|
|||
2
grafana-plugin/.gitignore
vendored
2
grafana-plugin/.gitignore
vendored
|
|
@ -19,4 +19,4 @@ frontend_enterprise
|
|||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
storageState.json
|
||||
/integration-tests/storageState.json
|
||||
|
|
|
|||
|
|
@ -1,20 +1,39 @@
|
|||
import { chromium, FullConfig, expect, Page } from '@playwright/test';
|
||||
import { test as setup, chromium, FullConfig, expect, Page, BrowserContext, APIResponse } from '@playwright/test';
|
||||
|
||||
import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME, IS_OPEN_SOURCE, ONCALL_API_URL } from './utils/constants';
|
||||
import { clickButton, getInputByName } from './utils/forms';
|
||||
import { goToGrafanaPage } from './utils/navigation';
|
||||
import { STORAGE_STATE } from '../playwright.config';
|
||||
|
||||
const IS_CLOUD = !IS_OPEN_SOURCE;
|
||||
const GLOBAL_SETUP_RETRIES = 3;
|
||||
|
||||
const makeGrafanaLoginRequest = async (browserContext: BrowserContext): Promise<APIResponse> =>
|
||||
browserContext.request.post(`${BASE_URL}/login`, {
|
||||
data: {
|
||||
user: GRAFANA_USERNAME,
|
||||
password: GRAFANA_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
const pollGrafanaInstanceUntilItIsHealthy = async (browserContext: BrowserContext): Promise<boolean> => {
|
||||
console.log('Polling the grafana instance to make sure it is healthy');
|
||||
|
||||
const res = await makeGrafanaLoginRequest(browserContext);
|
||||
|
||||
if (!res.ok()) {
|
||||
console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
return pollGrafanaInstanceUntilItIsHealthy(browserContext);
|
||||
}
|
||||
console.log('Grafana instance is available');
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* go to config page and wait for plugin icon to be available on left-hand navigation
|
||||
*/
|
||||
const configureOnCallPlugin = async (page: Page): Promise<void> => {
|
||||
// plugin configuration can safely be skipped for non open-source environments
|
||||
if (!IS_OPEN_SOURCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* go to the oncall plugin configuration page and wait for the page to be loaded
|
||||
*/
|
||||
|
|
@ -52,19 +71,26 @@ const globalSetup = async (config: FullConfig): Promise<void> => {
|
|||
const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 });
|
||||
const browserContext = await browser.newContext();
|
||||
|
||||
const res = await browserContext.request.post(`${BASE_URL}/login`, {
|
||||
data: {
|
||||
user: GRAFANA_USERNAME,
|
||||
password: GRAFANA_PASSWORD,
|
||||
},
|
||||
});
|
||||
if (IS_CLOUD) {
|
||||
/**
|
||||
* check that the grafana instance is available. If HTTP 503 is returned it means the
|
||||
* instance is currently unavailable. Poll until it is available
|
||||
*/
|
||||
await pollGrafanaInstanceUntilItIsHealthy(browserContext);
|
||||
}
|
||||
|
||||
const res = await makeGrafanaLoginRequest(browserContext);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
await browserContext.storageState({ path: './storageState.json' });
|
||||
await browserContext.storageState({ path: STORAGE_STATE });
|
||||
|
||||
// make sure the plugin has been configured
|
||||
const page = await browserContext.newPage();
|
||||
await configureOnCallPlugin(page);
|
||||
|
||||
if (IS_OPEN_SOURCE) {
|
||||
// plugin configuration can safely be skipped for cloud environments
|
||||
await configureOnCallPlugin(page);
|
||||
}
|
||||
|
||||
await browserContext.close();
|
||||
};
|
||||
|
|
@ -88,4 +114,13 @@ const globalSetupWithRetries = async (config: FullConfig): Promise<void> => {
|
|||
await globalSetup(config);
|
||||
};
|
||||
|
||||
export default globalSetupWithRetries;
|
||||
setup('Configure Grafana OnCall plugin', async ({}, { config }) => {
|
||||
/**
|
||||
* Unconditionally marks the setup as "slow", giving it triple the default timeout.
|
||||
* This is mostly useful for the rare case for Cloud Grafana instances where the instance may be down/unavailable
|
||||
* and we need to poll it until it is available
|
||||
*/
|
||||
setup.slow();
|
||||
|
||||
await globalSetupWithRetries(config);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import path from 'path';
|
||||
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
import { devices } from '@playwright/test';
|
||||
|
||||
|
|
@ -7,12 +9,13 @@ import { devices } from '@playwright/test';
|
|||
*/
|
||||
require('dotenv').config();
|
||||
|
||||
export const STORAGE_STATE = path.join(__dirname, 'integration-tests/storageState.json');
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './integration-tests',
|
||||
globalSetup: './integration-tests/globalSetup.ts',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
|
|
@ -38,8 +41,6 @@ const config: PlaywrightTestConfig = {
|
|||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
storageState: './storageState.json',
|
||||
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
|
|
@ -53,23 +54,33 @@ const config: PlaywrightTestConfig = {
|
|||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /globalSetup\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: STORAGE_STATE,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
storageState: STORAGE_STATE,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
storageState: STORAGE_STATE,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue