Skip to content

Commit 609335e

Browse files
committed
add Slack webhook UI and E2E tests
1 parent b3d8f1b commit 609335e

15 files changed

Lines changed: 892 additions & 45 deletions

e2e/tests/notification-channels.spec.ts

Lines changed: 154 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,58 +17,189 @@
1717
import {test, expect} from '@playwright/test';
1818
import {loginAsUser, BASE_URL, expectDualThemeScreenshot} from './utils';
1919

20-
test.beforeEach(async () => {});
20+
test('redirects unauthenticated user to home and shows toast', async ({
21+
page,
22+
}) => {
23+
await page.goto(`${BASE_URL}/settings/notification-channels`);
24+
25+
// Expect to be redirected to the home page.
26+
await expect(page).toHaveURL(`${BASE_URL}/`);
27+
// FYI: We do not assert the toast because it flashes on the screen due to the redirect.
28+
});
2129

2230
test.describe('Notification Channels Page', () => {
23-
test('redirects unauthenticated user to home and shows toast', async ({
24-
page,
25-
}) => {
31+
test.beforeEach(async ({page}) => {
32+
await loginAsUser(page, 'test user 1');
2633
await page.goto(`${BASE_URL}/settings/notification-channels`);
27-
28-
// Expect to be redirected to the home page.
29-
await expect(page).toHaveURL(BASE_URL);
30-
// FYI: We do not assert the toast because it flashes on the screen due to the redirect.
3134
});
3235

3336
test('authenticated user sees their email channel and coming soon messages', async ({
3437
page,
3538
}) => {
36-
// Log in as a test user
37-
await loginAsUser(page, 'test user 1');
38-
39-
// Navigate to the notification channels page
40-
await page.goto(`${BASE_URL}/settings/notification-channels`);
41-
42-
// Move the mouse to a neutral position to avoid hover effects on the screenshot
43-
await page.mouse.move(0, 0);
44-
45-
// Expect the URL to be correct
39+
// Expect the URL to be correct.
4640
await expect(page).toHaveURL(`${BASE_URL}/settings/notification-channels`);
4741

48-
// Verify Email panel content
42+
// Verify Email panel content.
4943
const emailPanel = page.locator('webstatus-notification-email-channels');
5044
await expect(emailPanel).toBeVisible();
5145
await expect(emailPanel).toContainText('test.user.1@example.com');
5246
await expect(emailPanel).toContainText('Enabled');
5347

54-
// Verify RSS panel content
48+
// Verify RSS panel content.
5549
const rssPanel = page.locator('webstatus-notification-rss-channels');
5650
await expect(rssPanel).toBeVisible();
5751
await expect(rssPanel).toContainText('Coming soon');
5852

59-
// Verify Webhook panel content
53+
// Verify Webhook panel content.
6054
const webhookPanel = page.locator(
6155
'webstatus-notification-webhook-channels',
6256
);
6357
await expect(webhookPanel).toBeVisible();
64-
await expect(webhookPanel).toContainText('Coming soon');
6558

66-
// Take a screenshot for visual regression
59+
// Move the mouse to a neutral position to avoid hover effects on the screenshot.
60+
await page.mouse.move(0, 0);
61+
62+
// Take a screenshot for visual regression.
6763
const pageContainer = page.locator('.page-container');
6864
await expectDualThemeScreenshot(
6965
page,
7066
pageContainer,
7167
'notification-channels-authenticated',
7268
);
7369
});
70+
71+
test('authenticated user can create and delete a slack webhook channel', async ({
72+
page,
73+
}) => {
74+
const nonce = Date.now();
75+
const webhookName = 'PlaywrightTestCreateDeleteTest ' + nonce;
76+
const webhookUrl =
77+
'https://hooks.slack.com/services/PLAYWRIGHT/TEST/' + nonce;
78+
79+
const webhookPanel = page.locator(
80+
'webstatus-notification-webhook-channels',
81+
);
82+
83+
// Don't assert that no webhook channels are configured.
84+
// There may be some from previous test runs or from manual testing.
85+
86+
// Click Create button.
87+
const createButton = webhookPanel.getByRole('button', {
88+
name: 'Create Webhook channel',
89+
});
90+
await expect(createButton).toBeVisible();
91+
await createButton.click();
92+
93+
// Fill the dialog.
94+
const dialog = webhookPanel
95+
.locator('webstatus-manage-notification-channel-dialog')
96+
.locator('sl-dialog');
97+
await expect(dialog).toBeVisible();
98+
99+
await dialog.getByRole('textbox', {name: 'Name'}).fill(webhookName);
100+
await dialog
101+
.getByRole('textbox', {name: 'Slack Webhook URL'})
102+
.fill(webhookUrl);
103+
104+
await dialog.getByRole('button', {name: 'Create', exact: true}).click();
105+
106+
// Verify it's in the list.
107+
await expect(dialog).not.toBeVisible();
108+
const channelItem = webhookPanel.locator('.channel-item', {
109+
hasText: webhookName,
110+
});
111+
await expect(channelItem).toBeVisible();
112+
113+
await channelItem.getByLabel('Delete').click();
114+
115+
const deleteDialog = webhookPanel.locator(
116+
'sl-dialog[label="Delete Webhook Channel"]',
117+
);
118+
await expect(deleteDialog).toBeVisible();
119+
await deleteDialog
120+
.getByRole('button', {name: 'Delete', exact: true})
121+
.click();
122+
123+
// Verify it's gone.
124+
await expect(channelItem).not.toBeVisible();
125+
});
126+
127+
test('authenticated user can update a slack webhook channel', async ({
128+
page,
129+
}) => {
130+
// Use a nonce to make sure we don't have any stale data from previous test runs.
131+
// Avoid using resetUserData() since it's an expensive operation.
132+
const nonce = Date.now();
133+
const originalName = 'PlaywrightTestUpdateOriginal ' + nonce;
134+
const originalUrl =
135+
'https://hooks.slack.com/services/PLAYWRIGHT/TEST/original-' + nonce;
136+
const updatedName = 'PlaywrightTestUpdateUpdated ' + nonce;
137+
const updatedUrl =
138+
'https://hooks.slack.com/services/PLAYWRIGHT/TEST/updated-' + nonce;
139+
140+
// Create a channel first.
141+
const webhookPanel = page.locator(
142+
'webstatus-notification-webhook-channels',
143+
);
144+
await page.waitForLoadState('networkidle');
145+
await webhookPanel
146+
.getByRole('button', {name: 'Create Webhook channel'})
147+
.click();
148+
const dialog = webhookPanel
149+
.locator('webstatus-manage-notification-channel-dialog')
150+
.locator('sl-dialog');
151+
await expect(dialog).toBeVisible({timeout: 10000});
152+
await dialog.getByRole('textbox', {name: 'Name'}).fill(originalName);
153+
await dialog
154+
.getByRole('textbox', {name: 'Slack Webhook URL'})
155+
.fill(originalUrl);
156+
await dialog.getByRole('button', {name: 'Create', exact: true}).click();
157+
158+
// Verify it was created.
159+
await expect(dialog).not.toBeVisible({timeout: 10000});
160+
const originalItem = webhookPanel.locator('.channel-item', {
161+
hasText: originalName,
162+
});
163+
await expect(originalItem).toBeVisible();
164+
165+
await originalItem.getByLabel('Edit').click();
166+
167+
// Verify current values in dialog.
168+
await expect(dialog).toBeVisible();
169+
await expect(dialog.getByRole('textbox', {name: 'Name'})).toHaveValue(
170+
originalName,
171+
);
172+
await expect(
173+
dialog.getByRole('textbox', {name: 'Slack Webhook URL'}),
174+
).toHaveValue(originalUrl);
175+
176+
// Update the values.
177+
await dialog.getByRole('textbox', {name: 'Name'}).fill(updatedName);
178+
await dialog
179+
.getByRole('textbox', {name: 'Slack Webhook URL'})
180+
.fill(updatedUrl);
181+
182+
await dialog.getByRole('button', {name: 'Save', exact: true}).click();
183+
184+
// Verify it was updated.
185+
await expect(dialog).not.toBeVisible({timeout: 10000});
186+
const updatedItem = webhookPanel.locator('.channel-item', {
187+
hasText: updatedName,
188+
});
189+
await expect(updatedItem).toBeVisible();
190+
await expect(originalItem).not.toBeVisible();
191+
192+
const deleteButton = updatedItem.locator('sl-button[aria-label="Delete"]');
193+
await expect(deleteButton).toBeVisible();
194+
await deleteButton.click();
195+
196+
const deleteDialog = webhookPanel.locator(
197+
'sl-dialog[label="Delete Webhook Channel"]',
198+
);
199+
await expect(deleteDialog).toBeVisible();
200+
await deleteDialog
201+
.getByRole('button', {name: 'Delete', exact: true})
202+
.click();
203+
await expect(updatedItem).not.toBeVisible();
204+
});
74205
});
1.83 KB
Loading
2.04 KB
Loading
2.39 KB
Loading
1.95 KB
Loading
2.51 KB
Loading
1.97 KB
Loading

frontend/src/static/js/api/client.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,87 @@ export class APIClient {
445445
});
446446
}
447447

448+
public async createNotificationChannel(
449+
token: string,
450+
channel: components['schemas']['CreateNotificationChannelRequest'],
451+
): Promise<components['schemas']['NotificationChannelResponse']> {
452+
const options: FetchOptions<
453+
FilterKeys<paths['/v1/users/me/notification-channels'], 'post'>
454+
> = {
455+
headers: {
456+
Authorization: `Bearer ${token}`,
457+
},
458+
body: channel,
459+
credentials: temporaryFetchOptions.credentials,
460+
};
461+
const response = await this.client.POST(
462+
'/v1/users/me/notification-channels',
463+
options,
464+
);
465+
const error = response.error;
466+
if (error !== undefined) {
467+
throw createAPIError(error);
468+
}
469+
return response.data;
470+
}
471+
472+
public async updateNotificationChannel(
473+
token: string,
474+
channelId: string,
475+
request: components['schemas']['UpdateNotificationChannelRequest'],
476+
): Promise<components['schemas']['NotificationChannelResponse']> {
477+
const options: FetchOptions<
478+
FilterKeys<
479+
paths['/v1/users/me/notification-channels/{channel_id}'],
480+
'patch'
481+
>
482+
> = {
483+
headers: {
484+
Authorization: `Bearer ${token}`,
485+
},
486+
params: {
487+
path: {
488+
channel_id: channelId,
489+
},
490+
},
491+
body: request,
492+
credentials: temporaryFetchOptions.credentials,
493+
};
494+
const response = await this.client.PATCH(
495+
'/v1/users/me/notification-channels/{channel_id}',
496+
options,
497+
);
498+
const error = response.error;
499+
if (error !== undefined) {
500+
throw createAPIError(error);
501+
}
502+
return response.data;
503+
}
504+
505+
public async deleteNotificationChannel(token: string, channelId: string) {
506+
const options = {
507+
...temporaryFetchOptions,
508+
params: {
509+
path: {
510+
channel_id: channelId,
511+
},
512+
},
513+
headers: {
514+
Authorization: `Bearer ${token}`,
515+
},
516+
};
517+
const response = await this.client.DELETE(
518+
'/v1/users/me/notification-channels/{channel_id}',
519+
options,
520+
);
521+
const error = response.error;
522+
if (error !== undefined) {
523+
throw createAPIError(error);
524+
}
525+
526+
return response.data;
527+
}
528+
448529
public async pingUser(
449530
token: string,
450531
pingOptions?: {githubToken?: string},
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {html, TemplateResult} from 'lit';
18+
import {type components} from 'webstatus.dev-backend';
19+
import './webhook-config-form.js';
20+
21+
import {ChannelConfigUpdate} from './channel-config-types.js';
22+
23+
type ChannelType = components['schemas']['NotificationChannel']['type'];
24+
type ChannelResponse = components['schemas']['NotificationChannelResponse'];
25+
26+
export const ChannelConfigRegistry = {
27+
renderConfig(
28+
type: ChannelType,
29+
channel: ChannelResponse | undefined,
30+
onUpdate: (update: ChannelConfigUpdate) => void,
31+
): TemplateResult {
32+
switch (type) {
33+
case 'webhook':
34+
return html`<webhook-config-form
35+
class="config-form"
36+
.channel=${channel}
37+
@change=${(e: CustomEvent<ChannelConfigUpdate>) => onUpdate(e.detail)}
38+
></webhook-config-form>`;
39+
case 'email':
40+
return html`<div>
41+
Email:
42+
${channel?.config.type === 'email'
43+
? (channel.config as components['schemas']['EmailConfig']).address
44+
: ''}
45+
(Verified)
46+
</div>`;
47+
default:
48+
return html`<p>Unsupported channel type: ${type}</p>`;
49+
}
50+
},
51+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {components} from 'webstatus.dev-backend';
18+
import {LitElement} from 'lit';
19+
import {property} from 'lit/decorators.js';
20+
21+
type UpdateRequest = components['schemas']['UpdateNotificationChannelRequest'];
22+
type UpdateMask = UpdateRequest['update_mask'][number];
23+
type ChannelResponse = components['schemas']['NotificationChannelResponse'];
24+
25+
export interface ChannelConfigUpdate {
26+
updates: Partial<UpdateRequest>;
27+
mask: UpdateMask[];
28+
}
29+
30+
export interface ChannelConfigComponent extends HTMLElement {
31+
channel?: ChannelResponse;
32+
getUpdate(): ChannelConfigUpdate;
33+
isDirty(): boolean;
34+
validate(): boolean;
35+
}
36+
37+
export abstract class ChannelConfigForm extends LitElement {
38+
@property({type: Object}) abstract channel?: ChannelResponse;
39+
abstract validate(): boolean;
40+
}

0 commit comments

Comments
 (0)