Skip to content

Commit 15271ac

Browse files
committed
add Slack webhook UI and E2E tests
1 parent b3d8f1b commit 15271ac

15 files changed

Lines changed: 834 additions & 45 deletions

e2e/tests/notification-channels.spec.ts

Lines changed: 147 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,58 +17,182 @@
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+
await webhookPanel
88+
.getByRole('button', {name: 'Create Webhook channel'})
89+
.click();
90+
91+
// Fill the dialog.
92+
const dialog = webhookPanel.locator('sl-dialog');
93+
await expect(dialog).toBeVisible();
94+
95+
await dialog.getByRole('textbox', {name: 'Name'}).fill(webhookName);
96+
await dialog
97+
.getByRole('textbox', {name: 'Slack Webhook URL'})
98+
.fill(webhookUrl);
99+
100+
await dialog.getByRole('button', {name: 'Create', exact: true}).click();
101+
102+
// Verify it's in the list.
103+
await expect(dialog).not.toBeVisible();
104+
const channelItem = webhookPanel.locator('.channel-item', {
105+
hasText: webhookName,
106+
});
107+
await expect(channelItem).toBeVisible();
108+
109+
// Delete it - handle the browser confirm dialog
110+
page.once('dialog', async dialog => {
111+
expect(dialog.message()).toBe(
112+
'Are you sure you want to delete this webhook channel?',
113+
);
114+
await dialog.accept();
115+
});
116+
117+
await channelItem.getByLabel('Delete').click();
118+
119+
// Verify it's gone.
120+
await expect(channelItem).not.toBeVisible();
121+
});
122+
123+
test('authenticated user can update a slack webhook channel', async ({
124+
page,
125+
}) => {
126+
// Use a nonce to make sure we don't have any stale data from previous test runs.
127+
// Avoid using resetUserData() since it's an expensive operation.
128+
const nonce = Date.now();
129+
const originalName = 'PlaywrightTestUpdateOriginal ' + nonce;
130+
const originalUrl =
131+
'https://hooks.slack.com/services/PLAYWRIGHT/TEST/original-' + nonce;
132+
const updatedName = 'PlaywrightTestUpdateUpdated ' + nonce;
133+
const updatedUrl =
134+
'https://hooks.slack.com/services/PLAYWRIGHT/TEST/updated-' + nonce;
135+
136+
// Create a channel first.
137+
const webhookPanel = page.locator(
138+
'webstatus-notification-webhook-channels',
139+
);
140+
await page.waitForLoadState('networkidle');
141+
await webhookPanel
142+
.getByRole('button', {name: 'Create Webhook channel'})
143+
.click();
144+
const dialog = webhookPanel.locator('sl-dialog');
145+
await expect(dialog).toBeVisible({timeout: 10000});
146+
await dialog.getByRole('textbox', {name: 'Name'}).fill(originalName);
147+
await dialog
148+
.getByRole('textbox', {name: 'Slack Webhook URL'})
149+
.fill(originalUrl);
150+
await dialog.getByRole('button', {name: 'Create', exact: true}).click();
151+
152+
// Verify it was created.
153+
await expect(dialog).not.toBeVisible({timeout: 10000});
154+
const originalItem = webhookPanel.locator('.channel-item', {
155+
hasText: originalName,
156+
});
157+
await expect(originalItem).toBeVisible();
158+
159+
await originalItem.getByLabel('Edit').click();
160+
161+
// Verify current values in dialog.
162+
await expect(dialog).toBeVisible();
163+
await expect(dialog.getByRole('textbox', {name: 'Name'})).toHaveValue(
164+
originalName,
165+
);
166+
await expect(
167+
dialog.getByRole('textbox', {name: 'Slack Webhook URL'}),
168+
).toHaveValue(originalUrl);
169+
170+
// Update the values.
171+
await dialog.getByRole('textbox', {name: 'Name'}).fill(updatedName);
172+
await dialog
173+
.getByRole('textbox', {name: 'Slack Webhook URL'})
174+
.fill(updatedUrl);
175+
176+
await dialog.getByRole('button', {name: 'Save', exact: true}).click();
177+
178+
// Verify it was updated.
179+
await expect(dialog).not.toBeVisible({timeout: 10000});
180+
const updatedItem = webhookPanel.locator('.channel-item', {
181+
hasText: updatedName,
182+
});
183+
await expect(updatedItem).toBeVisible();
184+
await expect(originalItem).not.toBeVisible();
185+
186+
// Cleanup.
187+
page.once('dialog', async dialog => {
188+
expect(dialog.message()).toBe(
189+
'Are you sure you want to delete this webhook channel?',
190+
);
191+
await dialog.accept();
192+
});
193+
const deleteButton = updatedItem.locator('sl-button[aria-label="Delete"]');
194+
await expect(deleteButton).toBeVisible();
195+
await deleteButton.click();
196+
await expect(updatedItem).not.toBeVisible();
197+
});
74198
});
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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
19+
type UpdateRequest = components['schemas']['UpdateNotificationChannelRequest'];
20+
type UpdateMask = UpdateRequest['update_mask'][number];
21+
type ChannelResponse = components['schemas']['NotificationChannelResponse'];
22+
23+
export interface ChannelConfigUpdate {
24+
updates: Partial<UpdateRequest>;
25+
mask: UpdateMask[];
26+
}
27+
28+
export interface ChannelConfigComponent extends HTMLElement {
29+
channel?: ChannelResponse;
30+
getUpdate(): ChannelConfigUpdate;
31+
isDirty(): boolean;
32+
validate(): boolean;
33+
}

0 commit comments

Comments
 (0)