diff --git a/e2e/tests/notification-channels.spec.ts b/e2e/tests/notification-channels.spec.ts index 69ef3c3a9..08cae9fdd 100644 --- a/e2e/tests/notification-channels.spec.ts +++ b/e2e/tests/notification-channels.spec.ts @@ -15,55 +15,57 @@ */ import {test, expect} from '@playwright/test'; -import {loginAsUser, BASE_URL, expectDualThemeScreenshot} from './utils'; +import { + loginAsUser, + BASE_URL, + expectDualThemeScreenshot, + resetUserData, +} from './utils'; -test.beforeEach(async () => {}); +test('redirects unauthenticated user to home and shows toast', async ({ + page, +}) => { + await page.goto(`${BASE_URL}/settings/notification-channels`); + + // Expect to be redirected to the home page. + await expect(page).toHaveURL(`${BASE_URL}/`); + // FYI: We do not assert the toast because it flashes on the screen due to the redirect. +}); test.describe('Notification Channels Page', () => { - test('redirects unauthenticated user to home and shows toast', async ({ - page, - }) => { + test.beforeEach(async ({page}) => { + await resetUserData(); + await loginAsUser(page, 'test user 1'); await page.goto(`${BASE_URL}/settings/notification-channels`); - - // Expect to be redirected to the home page. - await expect(page).toHaveURL(BASE_URL); - // FYI: We do not assert the toast because it flashes on the screen due to the redirect. }); test('authenticated user sees their email channel and coming soon messages', async ({ page, }) => { - // Log in as a test user - await loginAsUser(page, 'test user 1'); - - // Navigate to the notification channels page - await page.goto(`${BASE_URL}/settings/notification-channels`); - - // Move the mouse to a neutral position to avoid hover effects on the screenshot - await page.mouse.move(0, 0); - - // Expect the URL to be correct + // Expect the URL to be correct. await expect(page).toHaveURL(`${BASE_URL}/settings/notification-channels`); - // Verify Email panel content + // Verify Email panel content. const emailPanel = page.locator('webstatus-notification-email-channels'); await expect(emailPanel).toBeVisible(); await expect(emailPanel).toContainText('test.user.1@example.com'); await expect(emailPanel).toContainText('Enabled'); - // Verify RSS panel content + // Verify RSS panel content. const rssPanel = page.locator('webstatus-notification-rss-channels'); await expect(rssPanel).toBeVisible(); await expect(rssPanel).toContainText('Coming soon'); - // Verify Webhook panel content + // Verify Webhook panel content. const webhookPanel = page.locator( 'webstatus-notification-webhook-channels', ); await expect(webhookPanel).toBeVisible(); - await expect(webhookPanel).toContainText('Coming soon'); - // Take a screenshot for visual regression + // Move the mouse to a neutral position to avoid hover effects on the screenshot. + await page.mouse.move(0, 0); + + // Take a screenshot for visual regression. const pageContainer = page.locator('.page-container'); await expectDualThemeScreenshot( page, @@ -71,4 +73,140 @@ test.describe('Notification Channels Page', () => { 'notification-channels-authenticated', ); }); + + test('authenticated user can create and delete a slack webhook channel', async ({ + page, + }) => { + const nonce = Date.now(); + const webhookName = 'PlaywrightTestCreateDeleteTest ' + nonce; + const webhookUrl = + 'https://hooks.slack.com/services/PLAYWRIGHT/TEST/' + nonce; + + const webhookPanel = page.locator( + 'webstatus-notification-webhook-channels', + ); + + // Don't assert that no webhook channels are configured. + // There may be some from previous test runs or from manual testing. + + // Click Create button. + const createButton = webhookPanel.getByRole('button', { + name: 'Create Webhook channel', + }); + await expect(createButton).toBeVisible(); + await createButton.click(); + + // Fill the dialog. + const dialog = webhookPanel.locator( + 'webstatus-manage-notification-channel-dialog', + ); + await expect( + dialog.getByRole('heading', {name: 'Create Webhook Channel'}), + ).toBeVisible(); + + await dialog.getByRole('textbox', {name: 'Name'}).fill(webhookName); + await dialog + .getByRole('textbox', {name: 'Slack Webhook URL'}) + .fill(webhookUrl); + + await dialog.getByRole('button', {name: 'Create', exact: true}).click(); + + // Verify it's in the list. + await expect(dialog.locator('sl-dialog')).not.toBeVisible(); + const channelItem = webhookPanel.locator('.channel-item', { + hasText: webhookName, + }); + await expect(channelItem).toBeVisible(); + + await channelItem.locator('sl-button[label="Delete"]').click(); + + const deleteDialog = webhookPanel.locator('sl-dialog[open]'); + await expect(deleteDialog).toBeVisible(); + await deleteDialog + .getByRole('button', {name: 'Delete', exact: true}) + .click(); + + // Verify it's gone. + await expect(channelItem).not.toBeVisible(); + }); + + test('authenticated user can update a slack webhook channel', async ({ + page, + }) => { + // Use a nonce to make sure we don't have any stale data from previous test runs. + // Avoid using resetUserData() since it's an expensive operation. + const nonce = Date.now(); + const originalName = 'PlaywrightTestUpdateOriginal ' + nonce; + const originalUrl = + 'https://hooks.slack.com/services/PLAYWRIGHT/TEST/original-' + nonce; + const updatedName = 'PlaywrightTestUpdateUpdated ' + nonce; + const updatedUrl = + 'https://hooks.slack.com/services/PLAYWRIGHT/TEST/updated-' + nonce; + + // Create a channel first. + const webhookPanel = page.locator( + 'webstatus-notification-webhook-channels', + ); + await webhookPanel + .getByRole('button', {name: 'Create Webhook channel'}) + .click(); + const dialog = webhookPanel.locator( + 'webstatus-manage-notification-channel-dialog', + ); + await expect( + dialog.getByRole('heading', {name: 'Create Webhook Channel'}), + ).toBeVisible(); + await dialog.getByRole('textbox', {name: 'Name'}).fill(originalName); + await dialog + .getByRole('textbox', {name: 'Slack Webhook URL'}) + .fill(originalUrl); + await dialog.getByRole('button', {name: 'Create', exact: true}).click(); + + // Verify it was created. + await expect(dialog.locator('sl-dialog')).not.toBeVisible(); + const originalItem = webhookPanel.locator('.channel-item', { + hasText: originalName, + }); + await expect(originalItem).toBeVisible(); + + await originalItem.locator('sl-button[label="Edit"]').click(); + + // Verify current values in dialog. + await expect( + dialog.getByRole('heading', {name: 'Edit Webhook Channel'}), + ).toBeVisible(); + await expect(dialog.getByRole('textbox', {name: 'Name'})).toHaveValue( + originalName, + ); + await expect( + dialog.getByRole('textbox', {name: 'Slack Webhook URL'}), + ).toHaveValue(originalUrl); + + // Update the values. + await dialog.getByRole('textbox', {name: 'Name'}).fill(updatedName); + await dialog + .getByRole('textbox', {name: 'Slack Webhook URL'}) + .fill(updatedUrl); + + await dialog.getByRole('button', {name: 'Save', exact: true}).click(); + + // Verify it was updated. + await expect(dialog.locator('sl-dialog')).not.toBeVisible(); + const updatedItem = webhookPanel.locator('.channel-item', { + hasText: updatedName, + }); + await expect(updatedItem).toBeVisible(); + await expect(originalItem).not.toBeVisible(); + + const deleteButton = updatedItem.locator('sl-button[label="Delete"]'); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + + const deleteDialog = webhookPanel.locator('sl-dialog[open]'); + await expect(deleteDialog).toBeVisible(); + await deleteDialog + .getByRole('button', {name: 'Delete', exact: true}) + .click(); + await expect(updatedItem).not.toBeVisible(); + }); }); diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png index f695dffbe..f61663204 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png index 947c5397e..437367629 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png index cafa34c16..cd3637c47 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png index 6366bbdb0..fc4054633 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png index dfedc0e6d..2a198f7a8 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png index c9ab38728..eed4608a2 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png differ diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts index a43c0b262..29c2b51e8 100644 --- a/frontend/src/static/js/api/client.ts +++ b/frontend/src/static/js/api/client.ts @@ -571,6 +571,87 @@ export class APIClient { }); } + public async createNotificationChannel( + token: string, + channel: components['schemas']['CreateNotificationChannelRequest'], + ): Promise { + const options: FetchOptions< + FilterKeys + > = { + headers: { + Authorization: `Bearer ${token}`, + }, + body: channel, + credentials: fetchOptions.credentials, + }; + const response = await this.client.POST( + '/v1/users/me/notification-channels', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + return response.data; + } + + public async updateNotificationChannel( + token: string, + channelId: string, + request: components['schemas']['UpdateNotificationChannelRequest'], + ): Promise { + const options: FetchOptions< + FilterKeys< + paths['/v1/users/me/notification-channels/{channel_id}'], + 'patch' + > + > = { + headers: { + Authorization: `Bearer ${token}`, + }, + params: { + path: { + channel_id: channelId, + }, + }, + body: request, + credentials: fetchOptions.credentials, + }; + const response = await this.client.PATCH( + '/v1/users/me/notification-channels/{channel_id}', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + return response.data; + } + + public async deleteNotificationChannel(token: string, channelId: string) { + const options = { + ...fetchOptions, + params: { + path: { + channel_id: channelId, + }, + }, + headers: { + Authorization: `Bearer ${token}`, + }, + }; + const response = await this.client.DELETE( + '/v1/users/me/notification-channels/{channel_id}', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + + return response.data; + } + public async pingUser( token: string, pingOptions?: {githubToken?: string}, diff --git a/frontend/src/static/js/components/channel-config-registry.ts b/frontend/src/static/js/components/channel-config-registry.ts new file mode 100644 index 000000000..c554f2a58 --- /dev/null +++ b/frontend/src/static/js/components/channel-config-registry.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {html, TemplateResult} from 'lit'; +import {type components} from 'webstatus.dev-backend'; +import './webhook-config-form.js'; + +import {ChannelConfigUpdate} from './channel-config-types.js'; + +type ChannelType = components['schemas']['NotificationChannel']['type']; +type ChannelResponse = components['schemas']['NotificationChannelResponse']; + +export const ChannelConfigRegistry = { + renderConfig( + type: ChannelType, + channel: ChannelResponse | undefined, + onUpdate: (update: ChannelConfigUpdate) => void, + ): TemplateResult { + switch (type) { + case 'webhook': + return html`) => onUpdate(e.detail)} + >`; + case 'email': + return html`
+ Email: + ${channel?.config.type === 'email' ? channel.config.address : ''} + (Verified) +
`; + default: + return html`

Unsupported channel type: ${type}

`; + } + }, +}; diff --git a/frontend/src/static/js/components/channel-config-types.ts b/frontend/src/static/js/components/channel-config-types.ts new file mode 100644 index 000000000..c94bdbfe2 --- /dev/null +++ b/frontend/src/static/js/components/channel-config-types.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {components} from 'webstatus.dev-backend'; +import {LitElement} from 'lit'; +import {property} from 'lit/decorators.js'; + +type UpdateRequest = components['schemas']['UpdateNotificationChannelRequest']; +type UpdateMask = UpdateRequest['update_mask'][number]; +type ChannelResponse = components['schemas']['NotificationChannelResponse']; + +export interface ChannelConfigUpdate { + updates: Partial; + mask: UpdateMask[]; +} + +export interface ChannelConfigComponent extends HTMLElement { + channel?: ChannelResponse; + getUpdate(): ChannelConfigUpdate; + isDirty(): boolean; + validate(): boolean; +} + +export abstract class ChannelConfigForm extends LitElement { + @property({type: Object}) abstract channel?: ChannelResponse; + abstract validate(): boolean; +} diff --git a/frontend/src/static/js/components/test/webhook-config-form.test.ts b/frontend/src/static/js/components/test/webhook-config-form.test.ts new file mode 100644 index 000000000..3ef1325de --- /dev/null +++ b/frontend/src/static/js/components/test/webhook-config-form.test.ts @@ -0,0 +1,158 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, fixture, html} from '@open-wc/testing'; +import sinon from 'sinon'; +import '@shoelace-style/shoelace/dist/components/input/input.js'; +import '../webhook-config-form.js'; +import {WebhookConfigForm} from '../webhook-config-form.js'; +import type {components} from 'webstatus.dev-backend'; +import {SlInput} from '@shoelace-style/shoelace'; + +describe('webhook-config-form', () => { + let element: WebhookConfigForm; + + const mockChannel: components['schemas']['NotificationChannelResponse'] = { + id: 'test-channel-id', + type: 'webhook', + name: 'Original Webhook Name', + config: {type: 'webhook', url: 'https://hooks.slack.com/services/original'}, + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + describe('Create Mode (No Initial Channel)', () => { + beforeEach(async () => { + element = await fixture(html` + + `); + }); + + it('renders empty inputs initially', async () => { + expect(element).to.be.instanceOf(WebhookConfigForm); + const nameInput = + element.shadowRoot?.querySelector('#webhook-name'); + const urlInput = + element.shadowRoot?.querySelector('#webhook-url'); + expect(nameInput?.value).to.equal(''); + expect(urlInput?.value).to.equal(''); + }); + + it('is initially not dirty', async () => { + expect(element.isDirty()).to.be.false; + }); + + it('becomes dirty when inputs are typed into', async () => { + const nameInput = + element.shadowRoot?.querySelector('#webhook-name'); + nameInput!.value = 'New Name'; + nameInput?.dispatchEvent(new CustomEvent('sl-input')); + await element.updateComplete; + + expect(element.isDirty()).to.be.true; + }); + + it('emits a change event with full payload when input occurs', async () => { + const changeSpy = sinon.spy(); + element.addEventListener('change', changeSpy); + + const nameInput = + element.shadowRoot?.querySelector('#webhook-name'); + nameInput!.value = 'New Name'; + nameInput?.dispatchEvent(new CustomEvent('sl-input')); + await element.updateComplete; + + expect(changeSpy).to.have.been.calledOnce; + const detail = changeSpy.args[0][0].detail; + // In create mode, both name and config masks are always forced because !this.channel + expect(detail.mask).to.deep.equal(['name', 'config']); + expect(detail.updates).to.deep.equal({ + name: 'New Name', + config: {type: 'webhook', url: ''}, // URL hasn't been typed yet + }); + }); + + it('validates correctly by delegating to input elements', async () => { + const nameInput = + element.shadowRoot?.querySelector('#webhook-name'); + const urlInput = + element.shadowRoot?.querySelector('#webhook-url'); + + const nameStub = sinon.stub(nameInput!, 'reportValidity').returns(true); + const urlStub = sinon.stub(urlInput!, 'reportValidity').returns(false); + + expect(element.validate()).to.be.false; + expect(nameStub).to.have.been.calledOnce; + expect(urlStub).to.have.been.calledOnce; + }); + }); + + describe('Edit Mode (With Initial Channel)', () => { + beforeEach(async () => { + element = await fixture(html` + + `); + }); + + it('renders pre-filled inputs', async () => { + const nameInput = + element.shadowRoot?.querySelector('#webhook-name'); + const urlInput = + element.shadowRoot?.querySelector('#webhook-url'); + expect(nameInput?.value).to.equal('Original Webhook Name'); + expect(urlInput?.value).to.equal( + 'https://hooks.slack.com/services/original', + ); + }); + + it('is initially not dirty', async () => { + expect(element.isDirty()).to.be.false; + }); + + it('does not become dirty if typing the exact same value', async () => { + const nameInput = + element.shadowRoot?.querySelector('#webhook-name'); + nameInput!.value = 'Original Webhook Name'; + nameInput?.dispatchEvent(new CustomEvent('sl-input')); + await element.updateComplete; + + expect(element.isDirty()).to.be.false; + }); + + it('only includes modified fields in the update payload mask', async () => { + const changeSpy = sinon.spy(); + element.addEventListener('change', changeSpy); + + // We ONLY update the URL for an existing channel + const urlInput = + element.shadowRoot?.querySelector('#webhook-url'); + urlInput!.value = 'https://example.com/new-hook'; + urlInput?.dispatchEvent(new CustomEvent('sl-input')); + await element.updateComplete; + + expect(changeSpy).to.have.been.calledOnce; + const detail = changeSpy.args[0][0].detail; + + // Because we only typed in URL, name should NOT be in the mask + expect(detail.mask).to.deep.equal(['config']); + expect(detail.updates).to.deep.equal({ + config: {type: 'webhook', url: 'https://example.com/new-hook'}, + }); + expect(detail.updates.name).to.be.undefined; + }); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-manage-notification-channel-dialog.test.ts b/frontend/src/static/js/components/test/webstatus-manage-notification-channel-dialog.test.ts new file mode 100644 index 000000000..5634ff641 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-manage-notification-channel-dialog.test.ts @@ -0,0 +1,240 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, fixture, html} from '@open-wc/testing'; +import sinon from 'sinon'; +import '../webstatus-manage-notification-channel-dialog.js'; +import {ManageNotificationChannelDialog} from '../webstatus-manage-notification-channel-dialog.js'; +import type {components} from 'webstatus.dev-backend'; +import {SlButton, SlDialog} from '@shoelace-style/shoelace'; +import {ChannelConfigRegistry} from '../channel-config-registry.js'; +import {ChannelConfigUpdate} from '../channel-config-types.js'; +import '@shoelace-style/shoelace/dist/components/dialog/dialog.js'; +import '@shoelace-style/shoelace/dist/components/button/button.js'; + +// Mock used to isolate nested form rendering and avoid needing to cast to access the component field. +class MockConfigForm extends HTMLElement { + validate = () => true; +} + +if (!customElements.get('mock-config-form')) { + customElements.define('mock-config-form', MockConfigForm); +} + +describe('webstatus-manage-notification-channel-dialog', () => { + let element: ManageNotificationChannelDialog; + + const mockChannel: components['schemas']['NotificationChannelResponse'] = { + id: 'test-channel-id', + type: 'webhook', + name: 'My Webhook', + config: {type: 'webhook', url: 'https://hooks.slack.com/services/test'}, + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + beforeEach(async () => { + element = await fixture(html` + + `); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('renders correctly initially when closed', async () => { + expect(element).to.be.instanceOf(ManageNotificationChannelDialog); + expect(element.open).to.be.false; + const dialog = element.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.hasAttribute('open')).to.be.false; + }); + + it('sets title and button text correctly for create mode', async () => { + element.open = true; + element.mode = 'create'; + element.type = 'webhook'; + await element.updateComplete; + + const dialog = element.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.label).to.equal('Create Webhook Channel'); + + const saveBtn = element.shadowRoot?.querySelector( + 'sl-button[variant="primary"]', + ); + expect(saveBtn?.textContent?.trim()).to.equal('Create'); + }); + + it('sets title and button text correctly for edit mode', async () => { + element.open = true; + element.mode = 'edit'; + element.channel = mockChannel; + await element.updateComplete; + + const dialog = element.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.label).to.equal('Edit Webhook Channel'); + + const saveBtn = element.shadowRoot?.querySelector( + 'sl-button[variant="primary"]', + ); + expect(saveBtn?.textContent?.trim()).to.equal('Save'); + }); + + it('disables save button in edit mode if there are no pending updates', async () => { + element.open = true; + element.mode = 'edit'; + element.channel = mockChannel; + await element.updateComplete; + + const saveBtn = element.shadowRoot?.querySelector( + 'sl-button[variant="primary"]', + ); + expect(saveBtn?.disabled).to.be.true; + + // Simulate an update bubbling up from the internal config form registry + const updates: ChannelConfigUpdate = { + updates: {name: 'New Name'}, + mask: ['name'], + }; + element['_pendingUpdate'] = updates; + await element.updateComplete; + + expect(saveBtn?.disabled).to.be.false; + }); + + it('never proactively disables create button based on pending updates', async () => { + element.open = true; + element.mode = 'create'; + await element.updateComplete; + + const saveBtn = element.shadowRoot?.querySelector( + 'sl-button[variant="primary"]', + ); + expect(saveBtn?.disabled).to.be.false; + }); + + it('reflects loading state onto the primary action button', async () => { + element.open = true; + element.loading = true; + await element.updateComplete; + + const saveBtn = element.shadowRoot?.querySelector( + 'sl-button[variant="primary"]', + ); + expect(saveBtn?.loading).to.be.true; + }); + + it('emits sl-hide when the cancel button is clicked', async () => { + const hideSpy = sinon.spy(); + element.addEventListener('sl-hide', hideSpy); + + const cancelBtn = element.shadowRoot?.querySelector( + 'sl-button:not([variant="primary"])', + ); + cancelBtn?.click(); + + expect(hideSpy).to.have.been.calledOnce; + }); + + it('clears pending updates internally when the dialog is closed', async () => { + element.open = true; + await element.updateComplete; + + const updates: ChannelConfigUpdate = { + updates: {name: 'Foo'}, + mask: ['name'], + }; + element['_pendingUpdate'] = updates; + + element.open = false; // Trigger Lit Element lifecycle update + await element.updateComplete; + + expect(element['_pendingUpdate']).to.be.undefined; + }); + + it('does not emit save event if the nested config form fails validation', async () => { + const renderStub = sinon + .stub(ChannelConfigRegistry, 'renderConfig') + .returns(html` + + `); + + // Remove the beforeEach element to prevent overlay/backdrop conflicts + const testElement = await fixture(html` + + `); + + testElement.open = true; + await testElement.updateComplete; + + const mockForm = + testElement.shadowRoot?.querySelector('.config-form'); + mockForm!.validate = () => false; + + const saveSpy = sinon.spy(); + testElement.addEventListener('save', saveSpy); + + const saveBtn = testElement.shadowRoot?.querySelector( + 'sl-button[variant="primary"]', + ); + saveBtn?.click(); + + expect(saveSpy).to.not.have.been.called; + renderStub.restore(); + }); + + it('emits a save event packed with state details when the form is valid', async () => { + const renderStub = sinon + .stub(ChannelConfigRegistry, 'renderConfig') + .returns(html` + + `); + + const testElement = await fixture(html` + + `); + + testElement.open = true; + testElement.mode = 'edit'; + testElement.channel = mockChannel; + await testElement.updateComplete; + + const mockUpdate: ChannelConfigUpdate = { + updates: {name: 'Updated name'}, + mask: ['name'], + }; + testElement['_pendingUpdate'] = mockUpdate; + // Await update to ensure button goes enabled. + await testElement.updateComplete; + + const saveSpy = sinon.spy(); + testElement.addEventListener('save', saveSpy); + + const saveBtn = testElement.shadowRoot?.querySelector( + 'sl-button[variant="primary"]', + ); + saveBtn?.click(); + + expect(saveSpy).to.have.been.calledOnce; + expect(saveSpy.args[0][0].detail).to.deep.equal({ + mode: 'edit', + channelId: 'test-channel-id', + ...mockUpdate, + }); + renderStub.restore(); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts index 933cda626..526c9c5d8 100644 --- a/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts +++ b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts @@ -20,21 +20,22 @@ import '../../components/webstatus-notification-webhook-channels.js'; import '../../components/webstatus-notification-panel.js'; describe('webstatus-notification-webhook-channels', () => { - it('displays "Coming soon" message', async () => { + it('displays "No webhook channels configured." message', async () => { const el = await fixture(html` `); - const basePanel = el.shadowRoot!.querySelector( 'webstatus-notification-panel', ); assert.isNotNull(basePanel); - - const comingSoonText = basePanel!.querySelector( + const noChannelsText = basePanel!.querySelector( '[slot="content"] p', ) as HTMLParagraphElement; - assert.isNotNull(comingSoonText); - assert.include(comingSoonText.textContent, 'Coming soon'); + assert.isNotNull(noChannelsText); + assert.include( + noChannelsText.textContent, + 'No webhook channels configured.', + ); }); it('displays "Create Webhook channel" button', async () => { diff --git a/frontend/src/static/js/components/webhook-config-form.ts b/frontend/src/static/js/components/webhook-config-form.ts new file mode 100644 index 000000000..728ce303a --- /dev/null +++ b/frontend/src/static/js/components/webhook-config-form.ts @@ -0,0 +1,156 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {css, html} from 'lit'; +import {customElement, property, state, query} from 'lit/decorators.js'; +import {components} from 'webstatus.dev-backend'; +import {SlInput} from '@shoelace-style/shoelace'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import { + ChannelConfigComponent, + ChannelConfigUpdate, + ChannelConfigForm, +} from './channel-config-types.js'; + +type ChannelResponse = components['schemas']['NotificationChannelResponse']; +type WebhookConfig = components['schemas']['WebhookConfig']; +type UpdateRequest = components['schemas']['UpdateNotificationChannelRequest']; +type UpdateMask = UpdateRequest['update_mask'][number]; + +@customElement('webhook-config-form') +export class WebhookConfigForm + extends ChannelConfigForm + implements ChannelConfigComponent +{ + @property({type: Object}) channel?: ChannelResponse; + + protected get config(): WebhookConfig | undefined { + const config = this.channel?.config; + return config?.type === 'webhook' ? config : undefined; + } + + @state() private _pendingName?: string; + @state() private _pendingUrl?: string; + + @query('#webhook-name') + private _nameInput!: SlInput; + + @query('#webhook-url') + private _urlInput!: SlInput; + + static styles = [ + SHARED_STYLES, + css` + :host { + display: flex; + flex-direction: column; + gap: 16px; + } + .help-text { + font-size: 12px; + color: var(--unimportant-text-color); + margin: 0; + } + `, + ]; + + isDirty(): boolean { + const currentName = this.channel?.name ?? ''; + const currentUrl = this.config?.url ?? ''; + + const nameChanged = + this._pendingName !== undefined && this._pendingName !== currentName; + const urlChanged = + this._pendingUrl !== undefined && this._pendingUrl !== currentUrl; + + return nameChanged || urlChanged; + } + + validate(): boolean { + return this._nameInput.reportValidity() && this._urlInput.reportValidity(); + } + + getUpdate(): ChannelConfigUpdate { + const updates: Partial = {}; + const mask: UpdateMask[] = []; + + const currentName = this.channel?.name ?? ''; + const nameToUse = this._pendingName ?? currentName; + + const currentUrl = this.config?.url ?? ''; + const urlToUse = this._pendingUrl ?? currentUrl; + + const nameChanged = + this._pendingName !== undefined && this._pendingName !== currentName; + if (nameChanged || !this.channel) { + updates.name = nameToUse; + mask.push('name'); + } + + const urlChanged = + this._pendingUrl !== undefined && this._pendingUrl !== currentUrl; + if (urlChanged || !this.channel) { + // For config updates, we must send the entire config object as it's a 'oneOf' in OpenAPI. + updates.config = { + type: 'webhook', + url: urlToUse, + }; + mask.push('config'); + } + + return {updates, mask}; + } + + private _handleInput() { + this._pendingName = this._nameInput.value; + this._pendingUrl = this._urlInput.value; + this.dispatchEvent( + new CustomEvent('change', { + detail: this.getUpdate(), + bubbles: true, + composed: true, + }), + ); + } + + render() { + const currentName = this.channel?.name ?? ''; + const currentUrl = this.config?.url ?? ''; + + return html` + + +

+ Currently only Slack incoming webhooks are supported. +

+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-manage-notification-channel-dialog.ts b/frontend/src/static/js/components/webstatus-manage-notification-channel-dialog.ts new file mode 100644 index 000000000..dccb42dfd --- /dev/null +++ b/frontend/src/static/js/components/webstatus-manage-notification-channel-dialog.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {LitElement, PropertyValues, css, html} from 'lit'; +import {customElement, property, state, query} from 'lit/decorators.js'; +import {components} from 'webstatus.dev-backend'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import { + ChannelConfigUpdate, + ChannelConfigComponent, +} from './channel-config-types.js'; +import {ChannelConfigRegistry} from './channel-config-registry.js'; + +type ChannelType = components['schemas']['NotificationChannel']['type']; +type ChannelResponse = components['schemas']['NotificationChannelResponse']; + +@customElement('webstatus-manage-notification-channel-dialog') +export class ManageNotificationChannelDialog extends LitElement { + @property({type: Boolean}) open = false; + @property() mode: 'create' | 'edit' = 'create'; + @property() type: ChannelType = 'webhook'; + @property({type: Object}) channel?: ChannelResponse; + @property({type: Boolean}) loading = false; + + @state() private _pendingUpdate?: ChannelConfigUpdate; + + @query('.config-form') + private _configForm!: ChannelConfigComponent; + + static styles = [ + SHARED_STYLES, + css` + sl-dialog::part(panel) { + width: min(90vw, 500px); + } + .dialog-body { + display: flex; + flex-direction: column; + gap: 16px; + } + `, + ]; + + private _handleHide() { + this.dispatchEvent(new CustomEvent('sl-hide')); + } + + private _handleSave() { + if (this._configForm && !this._configForm.validate()) return; + + this.dispatchEvent( + new CustomEvent('save', { + detail: { + mode: this.mode, + channelId: this.channel?.id, + ...this._pendingUpdate, + }, + }), + ); + } + + render() { + return html` + +
+ ${ChannelConfigRegistry.renderConfig( + this.mode === 'edit' ? this.channel!.type : this.type, + this.channel, + u => (this._pendingUpdate = u), + )} +
+ + ${this.mode === 'create' ? 'Create' : 'Save'} + + Cancel +
+ `; + } + + updated(changedProperties: PropertyValues) { + if (changedProperties.has('open') && !this.open) { + this._pendingUpdate = undefined; + } + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-channels-page.ts b/frontend/src/static/js/components/webstatus-notification-channels-page.ts index a9d7a7bfc..f47d944de 100644 --- a/frontend/src/static/js/components/webstatus-notification-channels-page.ts +++ b/frontend/src/static/js/components/webstatus-notification-channels-page.ts @@ -56,6 +56,9 @@ export class WebstatusNotificationChannelsPage extends LitElement { @state() private emailChannels: NotificationChannelResponse[] = []; + @state() + private webhookChannels: NotificationChannelResponse[] = []; + private _channelsTask = new Task(this, { task: async () => { if (this.userContext === null) { @@ -79,6 +82,7 @@ export class WebstatusNotificationChannelsPage extends LitElement { return []; }); this.emailChannels = channels.filter(c => c.type === 'email'); + this.webhookChannels = channels.filter(c => c.type === 'webhook'); }, args: () => [this.userContext], }); @@ -91,11 +95,11 @@ export class WebstatusNotificationChannelsPage extends LitElement { - - - + + + `, complete: () => html` @@ -103,10 +107,13 @@ export class WebstatusNotificationChannelsPage extends LitElement { .channels=${this.emailChannels} > + this._channelsTask.run()} + > + - - `, error: e => { const errorMessage = diff --git a/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts index a78be6c55..b83491818 100644 --- a/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts +++ b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts @@ -15,17 +15,201 @@ */ import {LitElement, css, html} from 'lit'; -import {customElement} from 'lit/decorators.js'; +import {customElement, property, state} from 'lit/decorators.js'; +import {repeat} from 'lit/directives/repeat.js'; +import {consume} from '@lit/context'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import {APIClient} from '../api/client.js'; +import { + UserContext, + firebaseUserContext, +} from '../contexts/firebase-user-context.js'; +import {components} from 'webstatus.dev-backend'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import {toast} from '../utils/toast.js'; import './webstatus-notification-panel.js'; +import './webstatus-manage-notification-channel-dialog.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; @customElement('webstatus-notification-webhook-channels') export class WebstatusNotificationWebhookChannels extends LitElement { - static styles = css` - .card-body { - padding: 20px; - color: var(--unimportant-text-color); + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + @consume({context: firebaseUserContext, subscribe: true}) + @state() + userContext: UserContext | null | undefined; + + @property({type: Array}) + channels: NotificationChannelResponse[] = []; + + @state() + private _isManageDialogOpen = false; + + @state() + private _manageDialogMode: 'create' | 'edit' = 'create'; + + @state() + private _selectedChannel?: NotificationChannelResponse; + + @state() + private _isSaving = false; + + @state() + private _isDeletingId: string | null = null; + + @state() + private _isDeleteDialogOpen = false; + + @state() + private _channelToDelete?: NotificationChannelResponse; + + static styles = [ + SHARED_STYLES, + css` + .channel-item { + color: var(--default-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-bottom: 1px solid var(--border-color); + } + + .channel-item:last-child { + border-bottom: none; + } + + .channel-info { + display: flex; + flex-direction: column; + overflow: hidden; + } + + .channel-info .name { + font-size: 14px; + font-weight: bold; + } + + .channel-info .url { + font-size: 12px; + color: var(--unimportant-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .actions { + display: flex; + align-items: center; + gap: 8px; + } + + .empty-message { + padding: 16px; + color: var(--unimportant-text-color); + } + `, + ]; + + private _openCreateDialog() { + this._manageDialogMode = 'create'; + this._selectedChannel = undefined; + this._isManageDialogOpen = true; + } + + private _openEditDialog(channel: NotificationChannelResponse) { + this._manageDialogMode = 'edit'; + this._selectedChannel = channel; + this._isManageDialogOpen = true; + } + + private _closeManageDialog() { + this._isManageDialogOpen = false; + } + + private async _handleSave(e: CustomEvent) { + if (!this.userContext) { + return; + } + const {mode, channelId, updates} = e.detail; + + this._isSaving = true; + try { + const token = await this.userContext.user.getIdToken(); + if (mode === 'create') { + const resp = await this.apiClient.createNotificationChannel( + token, + updates, + ); + void toast(`Created webhook channel "${resp.name}".`, 'success'); + } else { + const updateRequest: components['schemas']['UpdateNotificationChannelRequest'] = + { + ...updates, + update_mask: e.detail.mask, + }; + const resp = await this.apiClient.updateNotificationChannel( + token, + channelId, + updateRequest, + ); + void toast(`Updated webhook channel "${resp.name}".`, 'success'); + } + this.dispatchEvent( + new CustomEvent('channel-changed', {bubbles: true, composed: true}), + ); + this._closeManageDialog(); + } catch (e) { + void toast( + `Failed to ${mode} webhook channel. Please try again.`, + 'danger', + 'exclamation-triangle', + ); + console.error(`Failed to ${mode} webhook channel`, e); + } finally { + this._isSaving = false; + } + } + + private _handleDeleteClick(channel: NotificationChannelResponse) { + this._channelToDelete = channel; + this._isDeleteDialogOpen = true; + } + + private _closeDeleteDialog() { + this._isDeleteDialogOpen = false; + this._channelToDelete = undefined; + } + + private async _confirmDelete() { + if (!this.userContext || !this._channelToDelete) return; + + this._isDeletingId = this._channelToDelete.id; + try { + const token = await this.userContext.user.getIdToken(); + await this.apiClient.deleteNotificationChannel( + token, + this._channelToDelete.id, + ); + this.dispatchEvent( + new CustomEvent('channel-changed', {bubbles: true, composed: true}), + ); + this._closeDeleteDialog(); + } catch (e) { + void toast( + 'Failed to delete webhook channel. Please try again.', + 'danger', + 'exclamation-triangle', + ); + console.error('Failed to delete webhook channel', e); + } finally { + this._isDeletingId = null; } - `; + } render() { return html` @@ -33,15 +217,97 @@ export class WebstatusNotificationWebhookChannels extends LitElement { Webhook
- Create Webhook - channel + + + Create Webhook channel +
-

Coming soon

+ ${this.channels.length === 0 + ? html`

No webhook channels configured.

` + : repeat( + this.channels, + channel => channel.id, + channel => html` +
+
+ ${channel.name} + ${channel.config.type === 'webhook' + ? channel.config.url + : ''} +
+
+ ${channel.status === 'enabled' + ? html`Enabled` + : html`Disabled`} + this._openEditDialog(channel)} + > + + + this._handleDeleteClick(channel)} + > + + +
+
+ `, + )}
+ + + + + +

Are you sure you want to delete this webhook channel?

+ + Cancel + + + Delete + +
`; } } diff --git a/util/cmd/load_fake_data/main.go b/util/cmd/load_fake_data/main.go index 96f25c270..cc2c11587 100644 --- a/util/cmd/load_fake_data/main.go +++ b/util/cmd/load_fake_data/main.go @@ -302,11 +302,43 @@ func resetTestData(ctx context.Context, spannerClient *gcpspanner.Client, authCl } slog.InfoContext(ctx, "Test user subscriptions reset.") + // Reset notification channels for each test user. + slog.InfoContext(ctx, "Resetting test user notification channels...") + for _, userID := range userIDs { + if err := resetUserNotificationChannels(ctx, spannerClient, userID); err != nil { + return err + } + } + slog.InfoContext(ctx, "Test user notification channels reset.") + slog.InfoContext(ctx, "Test user data reset complete.") return nil } +func resetUserNotificationChannels(ctx context.Context, spannerClient *gcpspanner.Client, userID string) error { + req := gcpspanner.ListNotificationChannelsRequest{ + UserID: userID, + PageSize: 1000, + PageToken: nil, + } + channels, _, err := spannerClient.ListNotificationChannels(ctx, req) + if err != nil { + return fmt.Errorf("failed to list notification channels for user %s: %w", userID, err) + } + + for _, ch := range channels { + err := spannerClient.DeleteNotificationChannel(ctx, ch.ID, userID) + if err != nil { + // Log the error but continue trying to delete others. + slog.WarnContext(ctx, "failed to delete notification channel, continuing", + "channelID", ch.ID, "userID", userID, "error", err) + } + } + + return nil +} + func generateReleases(ctx context.Context, c *gcpspanner.Client) (int, error) { releasesGenerated := 0 for _, browser := range browsers { diff --git a/workers/webhook/pkg/webhook/manual_trigger_test.go b/workers/webhook/pkg/webhook/manual_trigger_test.go new file mode 100644 index 000000000..514d38f3d --- /dev/null +++ b/workers/webhook/pkg/webhook/manual_trigger_test.go @@ -0,0 +1,115 @@ +//go:build manual + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +// TestManualSlackTrigger is a helper "test" that sends a real Slack message. +// To run this test, fill in a valid slackURL below and run: +// go test -v -tags=manual -run TestManualSlackTrigger ./workers/webhook/pkg/webhook +func TestManualSlackTrigger(t *testing.T) { + slackURL := "" // <--- FILL THIS IN + if slackURL == "" { + t.Skip("slackURL not set, skipping manual trigger test") + } + + testCases := []struct { + name string + query string + text string + }{ + { + name: "Search Query", + query: "baseline_status:newly", + text: "Manual Test: Search update for 'baseline_status:newly'", + }, + { + name: "Feature Query", + query: "id:\"anchor-positioning\"", + text: "Manual Test: Feature update for 'Anchor Positioning'", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + summary := workertypes.EventSummary{ + SchemaVersion: "v1", + Text: tc.text, + Categories: workertypes.SummaryCategories{Added: 1}, + Highlights: []workertypes.SummaryHighlight{ + { + Type: workertypes.SummaryHighlightTypeAdded, + FeatureID: "anchor-positioning", + FeatureName: "Anchor Positioning", + }, + }, + } + summaryRaw, _ := json.Marshal(summary) + + job := workertypes.IncomingWebhookDeliveryJob{ + WebhookDeliveryJob: workertypes.WebhookDeliveryJob{ + WebhookURL: slackURL, + SummaryRaw: summaryRaw, + Metadata: workertypes.DeliveryMetadata{ + EventID: "manual-trigger-event", + SearchID: "manual-search-id", + SearchName: tc.name, + Query: tc.query, + Frequency: workertypes.FrequencyImmediate, + GeneratedAt: time.Now(), + }, + Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + ChannelID: "manual-channel-id", + WebhookType: workertypes.WebhookTypeSlack, + }, + WebhookEventID: "manual-webhook-event-id", + } + + mgr := &slackManager{ + frontendBaseURL: "http://localhost:5555", + httpClient: &http.Client{}, + stateManager: &noopStateManager{}, // Don't try to write to Spanner + job: job, + } + + err := mgr.Send(context.Background()) + if err != nil { + t.Errorf("Failed to send Slack message for %s: %v", tc.name, err) + } else { + fmt.Printf("Slack message sent successfully for %s!\n", tc.name) + } + }) + } +} + +type noopStateManager struct{} + +func (n *noopStateManager) RecordSuccess(ctx context.Context, channelID string, sentAt time.Time, webhookEventID string) error { + return nil +} +func (n *noopStateManager) RecordFailure(ctx context.Context, channelID string, err error, failedAt time.Time, isPermanent bool, webhookEventID string) error { + return nil +} diff --git a/workers/webhook/pkg/webhook/sender.go b/workers/webhook/pkg/webhook/sender.go index 694415176..e1e724134 100644 --- a/workers/webhook/pkg/webhook/sender.go +++ b/workers/webhook/pkg/webhook/sender.go @@ -81,7 +81,7 @@ func (s *Sender) getManager(_ context.Context, job workertypes.IncomingWebhookDe } func (s *Sender) SendWebhook(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) error { - slog.InfoContext(ctx, "sending webhook", "channelID", job.ChannelID, "url", job.WebhookURL) + slog.InfoContext(ctx, "sending webhook", "channelID", job.ChannelID) mgr, err := s.getManager(ctx, job) if err != nil { diff --git a/workers/webhook/pkg/webhook/sender_test.go b/workers/webhook/pkg/webhook/sender_test.go index 8c32f8b21..f7e64ef97 100644 --- a/workers/webhook/pkg/webhook/sender_test.go +++ b/workers/webhook/pkg/webhook/sender_test.go @@ -17,6 +17,7 @@ package webhook import ( "context" "errors" + "io" "net/http" "strings" "testing" @@ -27,7 +28,13 @@ import ( func TestSender_SendWebhook_Success(t *testing.T) { mockHTTP := &mockHTTPClient{ - doFunc: func(_ *http.Request) (*http.Response, error) { + doFunc: func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + expectedLink := "View Results: https://webstatus.dev/?q=group%3Acss" + if !strings.Contains(string(body), expectedLink) { + t.Errorf("expected link %s not found in body", expectedLink) + } + return newTestResponse(http.StatusOK, "ok"), nil }, } @@ -83,6 +90,38 @@ func TestSender_SendWebhook_TransientFailure(t *testing.T) { } } +func TestSender_SendWebhook_FeatureDeepLink_Success(t *testing.T) { + mockHTTP := &mockHTTPClient{ + doFunc: func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + expectedLink := "View Results: https://webstatus.dev/features/anchor-positioning" + if !strings.Contains(string(body), expectedLink) { + t.Errorf("expected link %s not found in body", expectedLink) + } + + return newTestResponse(http.StatusOK, "ok"), nil + }, + } + + mockState := &mockChannelStateManager{ + successCalls: nil, + failureCalls: nil, + recordErr: nil, + } + sender := NewSender(mockHTTP, mockState, "https://webstatus.dev") + + job := newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/123", workertypes.WebhookTypeSlack, + "id:\"anchor-positioning\"", []byte(`{"text":"Test Body"}`)) + + err := sender.SendWebhook(context.Background(), job) + if err != nil { + t.Fatalf("SendWebhook failed: %v", err) + } + + verifySuccess(t, mockState) +} + func TestSender_SendWebhook_HTTPFailure(t *testing.T) { mockHTTP := &mockHTTPClient{ doFunc: func(_ *http.Request) (*http.Response, error) { diff --git a/workers/webhook/pkg/webhook/slack.go b/workers/webhook/pkg/webhook/slack.go index 34e785b25..7b7beb528 100644 --- a/workers/webhook/pkg/webhook/slack.go +++ b/workers/webhook/pkg/webhook/slack.go @@ -22,6 +22,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/GoogleChrome/webstatus.dev/lib/httputils" "github.com/GoogleChrome/webstatus.dev/lib/workertypes" @@ -56,9 +57,17 @@ func (s *slackSender) Send(ctx context.Context) error { return fmt.Errorf("%w: failed to unmarshal summary: %w", ErrPermanentWebhook, err) } + // Determine the correct results URL. + // 1. Check if it's a feature-specific query (id:"...") query := s.job.Metadata.Query - // Default search results page - resultsURL := fmt.Sprintf("%s/features?q=%s", s.frontendBaseURL, url.QueryEscape(query)) + var resultsURL string + if strings.HasPrefix(query, "id:\"") && strings.HasSuffix(query, "\"") { + featureKey := strings.TrimSuffix(strings.TrimPrefix(query, "id:\""), "\"") + resultsURL = fmt.Sprintf("%s/features/%s", s.frontendBaseURL, featureKey) + } else { + // 2. Default search results page (at the root) + resultsURL = fmt.Sprintf("%s/?q=%s", s.frontendBaseURL, url.QueryEscape(query)) + } payload := SlackPayload{ Text: fmt.Sprintf("WebStatus.dev Notification: %s\nQuery: %s\nView Results: %s", diff --git a/workers/webhook/pkg/webhook/slack_test.go b/workers/webhook/pkg/webhook/slack_test.go index 51df83b25..0bb561bb8 100644 --- a/workers/webhook/pkg/webhook/slack_test.go +++ b/workers/webhook/pkg/webhook/slack_test.go @@ -41,7 +41,7 @@ func TestSlackSender_Send(t *testing.T) { expectedPayload: &SlackPayload{ Text: "WebStatus.dev Notification: New feature landed\n" + "Query: group:css\n" + - "View Results: https://webstatus.dev/features?q=group%3Acss", + "View Results: https://webstatus.dev/?q=group%3Acss", }, expectedErr: nil, }, @@ -58,7 +58,7 @@ func TestSlackSender_Send(t *testing.T) { expectedPayload: &SlackPayload{ Text: "WebStatus.dev Notification: Test Body\n" + "Query: id:\"anchor-positioning\"\n" + - "View Results: https://webstatus.dev/features?q=id%3A%22anchor-positioning%22", + "View Results: https://webstatus.dev/features/anchor-positioning", }, expectedErr: nil, }, @@ -88,7 +88,7 @@ func TestSlackSender_Send(t *testing.T) { expectedPayload: &SlackPayload{ Text: "WebStatus.dev Notification: fail\n" + "Query: \n" + - "View Results: https://webstatus.dev/features?q=", + "View Results: https://webstatus.dev/?q=", }, expectedErr: ErrPermanentWebhook, }, @@ -105,7 +105,7 @@ func TestSlackSender_Send(t *testing.T) { expectedPayload: &SlackPayload{ Text: "WebStatus.dev Notification: retry\n" + "Query: \n" + - "View Results: https://webstatus.dev/features?q=", + "View Results: https://webstatus.dev/?q=", }, expectedErr: ErrTransientWebhook, },