Skip to content

Commit 1677e4a

Browse files
committed
e2e fixes and improvements
1 parent 3f5afd2 commit 1677e4a

5 files changed

Lines changed: 156 additions & 15 deletions

File tree

e2e/dogfooding.spec.ts

Lines changed: 126 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ It is used to test the following features:
2626
1. Create a stack
2727
2. Create a service with prerequisites, env vars, file overrides, and the StackCraft GitHub repository
2828
3. Clone, install, build and run the service
29-
4. Verify that the service is running
30-
5. Verify that the service is logging to the console
31-
6. The dog has eaten the food. Woof woof!
29+
4. Verify that the first start fails (port already in use)
30+
5. Override APP_SERVICE_PORT to a unique port and restart
31+
6. Verify that the service is running and reachable via HTTP
32+
7. Stop the service and remove the stack
3233
3334
`
3435

36+
const dogfoodingPort = browserName === 'chromium' ? 19090 : 19091
3537
const workingDirectory = `/tmp/e2e-dog-fooding-time-${uuid}`
3638

3739
await page.goto('/')
@@ -119,6 +121,36 @@ It is used to test the following features:
119121
await prereqForm.locator('button', { hasText: 'Add' }).click()
120122
await expect(page.locator('shade-noty-list')).toContainText('"Encryption Key" was added.')
121123

124+
// Prerequisite 6: DATABASE_URL (required by the service to connect to Postgres)
125+
await page.locator('button', { hasText: 'Add Prerequisite' }).click()
126+
await expect(prereqForm).toBeVisible()
127+
await prereqForm.locator('input[name="name"]').fill('Database URL')
128+
await typeSelect.locator('.select-trigger').click()
129+
await typeSelect.locator('.dropdown-item', { hasText: 'Environment Variable' }).click()
130+
await prereqForm.locator('input[name="variableName"]').fill('DATABASE_URL')
131+
await prereqForm.locator('button', { hasText: 'Add' }).click()
132+
await expect(page.locator('shade-noty-list')).toContainText('"Database URL" was added.')
133+
134+
// Prerequisite 7: APP_SERVICE_PORT (used to override the port for the dogfooding instance)
135+
await page.locator('button', { hasText: 'Add Prerequisite' }).click()
136+
await expect(prereqForm).toBeVisible()
137+
await prereqForm.locator('input[name="name"]').fill('Service Port')
138+
await typeSelect.locator('.select-trigger').click()
139+
await typeSelect.locator('.dropdown-item', { hasText: 'Environment Variable' }).click()
140+
await prereqForm.locator('input[name="variableName"]').fill('APP_SERVICE_PORT')
141+
await prereqForm.locator('button', { hasText: 'Add' }).click()
142+
await expect(page.locator('shade-noty-list')).toContainText('"Service Port" was added.')
143+
144+
// Prerequisite 8: MCP_PORT (must differ from the test host's MCP port to avoid conflict)
145+
await page.locator('button', { hasText: 'Add Prerequisite' }).click()
146+
await expect(prereqForm).toBeVisible()
147+
await prereqForm.locator('input[name="name"]').fill('MCP Port')
148+
await typeSelect.locator('.select-trigger').click()
149+
await typeSelect.locator('.dropdown-item', { hasText: 'Environment Variable' }).click()
150+
await prereqForm.locator('input[name="variableName"]').fill('MCP_PORT')
151+
await prereqForm.locator('button', { hasText: 'Add' }).click()
152+
await expect(page.locator('shade-noty-list')).toContainText('"MCP Port" was added.')
153+
122154
// --- Add local .env file override ---
123155
await page.locator('button', { hasText: 'Add Local File' }).click()
124156
await page.locator('input[placeholder="Relative path (e.g. .env)"]').fill('.env')
@@ -154,17 +186,101 @@ It is used to test the following features:
154186
await page.locator('button', { hasText: 'View Service' }).click()
155187
await expect(page.locator('shade-service-detail')).toBeVisible()
156188

157-
// Start the service (click the primary action in the header, not the stepper)
189+
// ======================================================================
190+
// Step 3: First start attempt — expect failure (port 9090 already in use)
191+
// ======================================================================
192+
158193
await page.getByTestId('page-header-actions').getByRole('button', { name: 'Start' }).click()
159194

160-
// Wait for the service to reach running state
161-
await expect(page.locator('shade-service-status-indicator')).toContainText('Running')
195+
// The spawned service will try to bind to port 9090 (default), which is
196+
// already occupied by the test host. Expect status to transition to Error.
197+
await expect(page.getByTestId('service-status-indicator')).toContainText('Error', { timeout: 60_000 })
162198

163-
// Navigate to the Logs tab via the tab bar
199+
// Navigate to Logs and verify the error is visible
164200
await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Logs' }).click()
165201
await expect(page.locator('shade-service-logs-tab')).toBeVisible()
166202

167-
// Verify that the log viewer is present with entries
168-
await expect(page.locator('shade-service-logs-tab shade-log-viewer')).toBeVisible()
169-
await expect(page.locator('shade-service-logs-tab shade-log-viewer')).not.toContainText('No log output yet.')
203+
const logViewer = page.locator('shade-service-logs-tab shade-log-viewer')
204+
await expect(logViewer).toBeVisible()
205+
await expect(logViewer).toContainText(/EADDRINUSE|address already in use/, { timeout: 10_000 })
206+
207+
// ======================================================================
208+
// Step 4: Set APP_SERVICE_PORT env override to a unique port and restart
209+
// ======================================================================
210+
211+
// Navigate to the Configuration tab
212+
await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Configuration' }).click()
213+
await expect(page.locator('shade-service-config-tab')).toBeVisible()
214+
215+
// Set APP_SERVICE_PORT override to a unique port per browser
216+
const portOverride = page.getByTestId('env-override-APP_SERVICE_PORT')
217+
await expect(portOverride).toBeVisible()
218+
219+
const portSourceSelect = portOverride.locator('shade-select').first()
220+
await portSourceSelect.locator('.select-trigger').click()
221+
await portSourceSelect.locator('.dropdown-item', { hasText: 'Custom value' }).click()
222+
await portOverride.locator('shade-input input').fill(String(dogfoodingPort))
223+
224+
// Set MCP_PORT override to avoid conflict with the test host's MCP server
225+
const mcpOverride = page.getByTestId('env-override-MCP_PORT')
226+
await expect(mcpOverride).toBeVisible()
227+
228+
const mcpSourceSelect = mcpOverride.locator('shade-select').first()
229+
await mcpSourceSelect.locator('.select-trigger').click()
230+
await mcpSourceSelect.locator('.dropdown-item', { hasText: 'Custom value' }).click()
231+
await mcpOverride.locator('shade-input input').fill(String(dogfoodingPort + 1))
232+
233+
// Save the overrides and wait for the save to complete
234+
const saveButton = page.getByTestId('save-env-overrides')
235+
await saveButton.click()
236+
await expect(saveButton).not.toHaveAttribute('loading', { timeout: 10_000 })
237+
238+
// Navigate back to the Overview tab and start the service again
239+
await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Overview' }).click()
240+
await page.getByTestId('page-header-actions').getByRole('button', { name: 'Start' }).click()
241+
242+
// Wait for the service to reach running state on the new port
243+
await expect(page.getByTestId('service-status-indicator')).toContainText('Running', { timeout: 60_000 })
244+
245+
// ======================================================================
246+
// Step 5: Verify logs and fire an HTTP request to the running service
247+
// ======================================================================
248+
249+
await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Logs' }).click()
250+
await expect(page.locator('shade-service-logs-tab')).toBeVisible()
251+
252+
const successLogViewer = page.locator('shade-service-logs-tab shade-log-viewer')
253+
await expect(successLogViewer).toBeVisible()
254+
await expect(successLogViewer).not.toContainText('No log output yet.')
255+
256+
// Verify the service is actually reachable on the new port
257+
await expect(async () => {
258+
const response = await page.request.get(`http://localhost:${dogfoodingPort}`)
259+
expect(response.status()).toBe(200)
260+
}).toPass({ timeout: 30_000 })
261+
262+
// ======================================================================
263+
// Step 6: Stop the service and remove the stack
264+
// ======================================================================
265+
266+
// Stop the running service (SIGTERM may cause non-zero exit → "Error" or clean → "Stopped")
267+
await page.getByTestId('page-header-actions').getByRole('button', { name: 'Stop' }).click()
268+
await expect(page.getByTestId('service-status-indicator')).toContainText(/Stopped|Error/, { timeout: 30_000 })
269+
270+
// Navigate to the main dashboard, then to the stack, then to Edit Stack
271+
await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click()
272+
await expect(page.locator('stack-list-dashboard')).toBeVisible()
273+
274+
await page.locator('stack-list-dashboard shade-card', { hasText: displayName }).click()
275+
await expect(page.getByTestId('page-header-title')).toContainText(displayName)
276+
277+
await page.locator('a', { hasText: 'Edit Stack' }).click()
278+
await expect(page.locator('shade-edit-stack')).toBeVisible()
279+
280+
// Delete the stack
281+
await page.locator('button', { hasText: 'Delete Stack' }).click()
282+
await page.locator('shade-dialog .dialog-confirm-btn').click()
283+
284+
await expect(page.locator('shade-noty-list')).toContainText(`"${displayName}" was deleted.`)
285+
await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10_000 })
170286
})

e2e/smoke.spec.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { expect, test } from '@playwright/test'
22
import { login } from './helpers.js'
33

44
test.describe.serial('App Flow', () => {
5+
let stackName: string
6+
let displayName: string
7+
58
test('Login and view dashboard', async ({ page }) => {
69
await page.goto('/')
710
await login(page)
@@ -10,8 +13,8 @@ test.describe.serial('App Flow', () => {
1013
test('Create stack, create service, verify dashboard', async ({ page, browserName }) => {
1114
const uuid = crypto.randomUUID()
1215

13-
const stackName = `e2e-test-stack-${uuid}`
14-
const displayName = `E2E Test Stack - ${browserName} - ${uuid}`
16+
stackName = `e2e-test-stack-${uuid}`
17+
displayName = `E2E Test Stack - ${browserName} - ${uuid}`
1518

1619
const workingDirectory = `/tmp/e2e-test-stack-${uuid}`
1720

@@ -75,4 +78,24 @@ test.describe.serial('App Flow', () => {
7578

7679
await expect(page.locator('shade-services-list')).toBeVisible()
7780
})
81+
82+
test('Clean up stack', async ({ page }) => {
83+
await page.goto('/')
84+
await login(page)
85+
86+
// Click on the stack card in the main dashboard to open its overview
87+
await page.locator('stack-list-dashboard shade-card', { hasText: displayName }).click()
88+
await expect(page.getByTestId('page-header-title')).toContainText(displayName)
89+
90+
// Navigate to Edit Stack via the header button
91+
await page.locator('a', { hasText: 'Edit Stack' }).click()
92+
await expect(page.locator('shade-edit-stack')).toBeVisible()
93+
94+
// Delete the stack
95+
await page.locator('button', { hasText: 'Delete Stack' }).click()
96+
await page.locator('shade-dialog .dialog-confirm-btn').click()
97+
98+
await expect(page.locator('shade-noty-list')).toContainText(`"${displayName}" was deleted.`)
99+
await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10000 })
100+
})
78101
})

frontend/src/components/service-env-overrides.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const ServiceEnvOverrides = Shade<ServiceEnvOverridesProps>({
7979
return (
8080
<Paper
8181
elevation={0}
82+
data-testid={`env-override-${varName}`}
8283
style={{
8384
display: 'flex',
8485
flexDirection: 'column',
@@ -131,8 +132,7 @@ export const ServiceEnvOverrides = Shade<ServiceEnvOverridesProps>({
131132
...(isGloballyAvailable ? [{ value: 'inherit', label: 'Inherit from system' }] : []),
132133
{ value: 'custom', label: 'Custom value' },
133134
]}
134-
onchange={(ev) => {
135-
const val = (ev.target as HTMLSelectElement).value
135+
onValueChange={(val) => {
136136
if (val === '') {
137137
const next = { ...editState }
138138
delete next[varName]
@@ -179,6 +179,7 @@ export const ServiceEnvOverrides = Shade<ServiceEnvOverridesProps>({
179179
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
180180
<Button
181181
variant="contained"
182+
data-testid="save-env-overrides"
182183
loading={isSaving}
183184
onclick={() => void handleSave()}
184185
startIcon={<Icon icon={icons.check} size="small" />}

frontend/src/components/service-status-indicator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const ServiceStatusIndicator = Shade<ServiceStatusIndicatorProps>({
2626
render: ({ props }) => {
2727
const { label, color } = getAggregateStatus(props.service)
2828
return (
29-
<Chip variant="outlined" color={color} size="small">
29+
<Chip variant="outlined" color={color} size="small" data-testid="service-status-indicator">
3030
{label}
3131
</Chip>
3232
)

frontend/src/pages/stacks/edit-stack.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export const EditStack = Shade<EditStackProps>({
126126
variant="outlined"
127127
size="small"
128128
color="error"
129+
data-testid="delete-stack-button"
129130
loading={isDeleting}
130131
onclick={() => setIsConfirmingDelete(true)}
131132
startIcon={<Icon icon={icons.trash} size="small" />}

0 commit comments

Comments
 (0)