Skip to content

Commit 9bca00a

Browse files
committed
test(installation-media): write e2e test for the wizard
Write an E2E test for the installation media wizard to create, download, and delete an image. Validates information on the confirmation page as well. Signed-off-by: Edward Sammut Alessi <edward.sammutalessi@siderolabs.com>
1 parent a2eedd8 commit 9bca00a

File tree

5 files changed

+230
-16
lines changed

5 files changed

+230
-16
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2026 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
import { stat } from 'node:fs/promises'
6+
7+
import { faker } from '@faker-js/faker'
8+
import { milliseconds } from 'date-fns'
9+
import { load } from 'js-yaml'
10+
11+
import { expect, test } from '../auth_fixtures'
12+
13+
test.describe.configure({ mode: 'parallel' })
14+
15+
test('Download installation media', async ({ page }, testInfo) => {
16+
test.slow()
17+
18+
await test.step('Go to wizard', async () => {
19+
await page.goto('/')
20+
await page.getByRole('link', { name: 'Download Installation Media' }).click()
21+
22+
const firstStepHeading = page.getByRole('heading', { name: 'Create New Media' })
23+
24+
const isVisible = await firstStepHeading
25+
.waitFor({ timeout: milliseconds({ seconds: 2 }) })
26+
.then(() => true)
27+
.catch(() => false)
28+
29+
// Only click "Create New" if we don't auto redirect (redirects directly to wizard if no configs exist)
30+
if (!isVisible) {
31+
await page.getByRole('link', { name: 'Create New' }).click()
32+
}
33+
34+
await expect(firstStepHeading).toBeVisible()
35+
})
36+
37+
await test.step('Entry step', async () => {
38+
await page.getByRole('radio', { name: 'Bare-metal Machine' }).click()
39+
await expect(page.getByRole('radio', { name: 'Bare-metal Machine' })).toBeChecked()
40+
41+
await page.getByRole('link', { name: 'Next' }).click()
42+
})
43+
44+
await test.step('Talos version step', async () => {
45+
await page.getByRole('combobox', { name: 'Choose Talos Linux Version' }).click()
46+
await page.getByRole('option', { name: '1.12.0' }).click()
47+
await expect(page.getByRole('combobox', { name: 'Choose Talos Linux Version' })).toHaveText(
48+
'1.12.0',
49+
)
50+
51+
await page.getByRole('combobox', { name: 'Join Token' }).click()
52+
await page.getByRole('option', { name: 'initial token' }).click()
53+
await expect(page.getByRole('combobox', { name: 'Join Token' })).toHaveText('initial token')
54+
55+
await page.getByText('Tunnel Omni management').click()
56+
await expect(page.getByRole('checkbox', { name: 'Tunnel Omni management' })).toBeChecked()
57+
58+
await page.getByRole('button', { name: 'new label' }).click()
59+
await page.getByRole('textbox').first().fill('foo:bar')
60+
await page.getByRole('textbox').first().press('Enter')
61+
await expect(page.getByRole('button', { name: 'foo:bar' })).toBeVisible()
62+
63+
await page.getByRole('link', { name: 'Next' }).click()
64+
})
65+
66+
await test.step('Architecture step', async () => {
67+
await page.getByRole('radio', { name: 'arm64' }).click()
68+
await expect(page.getByRole('radio', { name: 'arm64' })).toBeChecked()
69+
70+
await page.getByRole('checkbox', { name: 'SecureBoot' }).click()
71+
await expect(page.getByRole('checkbox', { name: 'SecureBoot' })).toBeChecked()
72+
73+
await page.getByRole('link', { name: 'Next' }).click()
74+
})
75+
76+
await test.step('System extensions step', async () => {
77+
await page.getByPlaceholder('Search').fill('hello')
78+
await page.getByText('siderolabs/hello-world-service').click()
79+
await expect(
80+
page.getByRole('checkbox', { name: 'siderolabs/hello-world-service' }),
81+
).toBeChecked()
82+
83+
await page.getByRole('link', { name: 'Next' }).click()
84+
})
85+
86+
await test.step('Extra args step', async () => {
87+
await page.getByRole('radio', { name: 'Auto' }).click()
88+
await page.locator('.flex.max-h-full').first().click()
89+
90+
await page
91+
.getByRole('textbox', { name: 'Extra kernel command line' })
92+
.fill(`-console console=tty0`)
93+
94+
await page.getByRole('link', { name: 'Next' }).click()
95+
})
96+
97+
const savedPresetName = `e2e-media-${faker.string.alphanumeric(8)}`
98+
99+
let schematicId: string
100+
101+
await test.step('Confirmation step', async () => {
102+
await page.getByRole('button', { name: 'Copy schematic ID' }).click()
103+
104+
schematicId = await page.evaluate(() => navigator.clipboard.readText())
105+
expect(schematicId, 'Expect schematic ID to be valid').toMatch(/[a-zA-Z0-9]{64}/)
106+
107+
await page.getByRole('button', { name: 'Copy schematic YAML' }).click()
108+
109+
const schematicYml = await page.evaluate(() => navigator.clipboard.readText())
110+
const parsedSchematicYml = load(schematicYml)
111+
await testInfo.attach('schematic.yaml', {
112+
body: schematicYml,
113+
contentType: 'application/yaml',
114+
})
115+
116+
expect(parsedSchematicYml, 'Expect YAML to match expected shape').toEqual({
117+
customization: {
118+
extraKernelArgs: [
119+
expect.stringContaining('siderolink.api=grpc://'),
120+
expect.stringContaining('talos.events.sink='),
121+
expect.stringContaining('talos.logging.kernel='),
122+
'-console',
123+
'console=tty0',
124+
],
125+
meta: [{ key: 12, value: expect.any(String) }],
126+
systemExtensions: {
127+
officialExtensions: ['siderolabs/hello-world-service'],
128+
},
129+
},
130+
})
131+
132+
await expect(
133+
page.getByText(
134+
`https://factory.talos.dev/image/${schematicId}/1.12.0/metal-arm64-secureboot.iso`,
135+
),
136+
).toBeVisible()
137+
138+
await expect(
139+
page.getByText(
140+
`https://factory.talos.dev/image/${schematicId}/1.12.0/metal-arm64-secureboot.raw.zst`,
141+
),
142+
).toBeVisible()
143+
144+
await expect(
145+
page.getByText(`https://pxe.factory.talos.dev/${schematicId}/1.12.0/metal-arm64-secureboot`),
146+
).toBeVisible()
147+
148+
await page.getByRole('button', { name: 'Save' }).click()
149+
await page.getByRole('textbox', { name: 'Name:' }).fill(savedPresetName)
150+
await page.getByRole('textbox', { name: 'Name:' }).click()
151+
await page.getByLabel('Save preset').getByRole('button', { name: 'Save' }).click()
152+
await page.getByRole('link', { name: 'Finished' }).click()
153+
})
154+
155+
const presetRow = page.getByRole('row', { name: savedPresetName })
156+
157+
await test.step('Download the image', async () => {
158+
await presetRow.getByLabel('download').click()
159+
160+
const isoRow = page.getByRole('row', { name: 'SecureBoot ISO' })
161+
await isoRow.getByLabel('copy link').click()
162+
163+
const isoLink = await page.evaluate(() => navigator.clipboard.readText())
164+
expect(isoLink).toBe(
165+
`https://factory.talos.dev/image/${schematicId}/1.12.0/metal-arm64-secureboot.iso`,
166+
)
167+
168+
await isoRow.getByLabel('download').click()
169+
170+
const [download] = await Promise.all([
171+
page.waitForEvent('download'),
172+
expect(page.getByText('Generating Image')).toBeVisible(),
173+
])
174+
175+
const filePath = testInfo.outputPath(download.suggestedFilename())
176+
await download.saveAs(filePath)
177+
178+
const { size } = await stat(filePath)
179+
180+
expect(size, 'Expect ISO to be at least 50MB').toBeGreaterThan(50 * 1024 * 1024)
181+
182+
await page.getByRole('button', { name: 'Close', exact: true }).click()
183+
})
184+
185+
await test.step('Delete the image', async () => {
186+
await presetRow.getByLabel('delete').click()
187+
188+
await expect(
189+
page.getByText(`Are you sure you want to delete preset "${savedPresetName}"?`),
190+
).toBeVisible()
191+
192+
await page.getByRole('button', { name: 'Confirm' }).click()
193+
194+
await expect(page.getByText(`Deleted preset ${savedPresetName}`)).toBeVisible()
195+
await expect(presetRow).toBeHidden()
196+
})
197+
})

frontend/src/components/common/Checkbox/TCheckbox.vue

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Use of this software is governed by the Business Source License
55
included in the LICENSE file.
66
-->
77
<script setup lang="ts">
8-
import TAnimation from '@/components/common/Animation/TAnimation.vue'
8+
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
9+
910
import TIcon from '@/components/common/Icon/TIcon.vue'
1011
1112
type Props = {
@@ -21,19 +22,18 @@ const checked = defineModel<boolean>({ default: false })
2122

2223
<template>
2324
<label class="inline-flex cursor-pointer items-center gap-2 has-disabled:cursor-not-allowed">
24-
<input v-model="checked" type="checkbox" :disabled="disabled" class="peer sr-only fixed" />
25-
26-
<div
27-
class="flex size-3.5 items-center justify-center rounded-xs border border-naturals-n7 peer-checked:border-primary-p6 peer-checked:bg-primary-p6 peer-disabled:border-naturals-n5 peer-disabled:bg-naturals-n4"
25+
<CheckboxRoot
26+
v-model="checked"
27+
:disabled
28+
class="flex size-3.5 items-center justify-center rounded-xs border border-naturals-n7 transition-colors data-disabled:border-naturals-n5 data-disabled:bg-naturals-n4 not-data-disabled:data-[state=checked]:border-primary-p6 not-data-disabled:data-[state=checked]:bg-primary-p6"
2829
>
29-
<TAnimation>
30+
<CheckboxIndicator class="transition-opacity data-[state=unchecked]:opacity-0" force-mount>
3031
<TIcon
31-
v-show="checked"
3232
class="size-full fill-current text-primary-p3"
3333
:icon="indeterminate ? 'minus' : 'check'"
3434
/>
35-
</TAnimation>
36-
</div>
35+
</CheckboxIndicator>
36+
</CheckboxRoot>
3737

3838
<span
3939
v-if="label || $slots.default"

frontend/src/components/common/CodeBlock/CodeBlock.vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,24 @@ Use of this software is governed by the Business Source License
55
included in the LICENSE file.
66
-->
77
<script setup lang="ts">
8+
import type { ComponentProps } from 'vue-component-type-helpers'
9+
810
import CopyButton from '@/components/common/CopyButton/CopyButton.vue'
911
10-
const { code = '' } = defineProps<{ code?: string }>()
12+
interface Props {
13+
code?: string
14+
buttonAttrs?: /* @vue-ignore */ Omit<ComponentProps<typeof CopyButton>, 'text'>
15+
}
16+
17+
const { code = '', buttonAttrs } = defineProps<Props>()
1118
</script>
1219

1320
<template>
1421
<div class="relative rounded border border-naturals-n7 bg-naturals-n2 text-naturals-n14">
1522
<div
1623
class="absolute top-2 right-2 z-10 flex items-center justify-center rounded-md p-1 backdrop-blur"
1724
>
18-
<CopyButton aria-label="Copy" :text="code" />
25+
<CopyButton v-bind="buttonAttrs" :text="code" />
1926
</div>
2027

2128
<div class="p-1">

frontend/src/components/common/CopyButton/CopyButton.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ included in the LICENSE file.
66
-->
77
<script setup lang="ts">
88
import { useClipboard } from '@vueuse/core'
9+
import type { ButtonHTMLAttributes } from 'vue'
910
1011
import TIcon from '@/components/common/Icon/TIcon.vue'
1112
12-
const { text = '' } = defineProps<{ text?: string }>()
13+
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {
14+
text?: string
15+
}
16+
17+
const { text = '' } = defineProps<Props>()
1318
const { copy, copied } = useClipboard({ copiedDuring: 1000 })
1419
</script>
1520

frontend/src/views/omni/InstallationMedia/Steps/Confirmation.vue

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,10 @@ const installerImage = computed(() =>
153153
<p class="flex items-center gap-1">
154154
Your image schematic ID is:
155155
<code class="rounded bg-naturals-n4 px-2 py-1 wrap-anywhere">{{ schematic.id }}</code>
156-
<CopyButton :text="schematic.id" />
156+
<CopyButton aria-label="Copy schematic ID" :text="schematic.id" />
157157
</p>
158158

159-
<CodeBlock :code="schematic.yml" />
159+
<CodeBlock :button-attrs="{ 'aria-label': 'Copy schematic YAML' }" :code="schematic.yml" />
160160

161161
<h3 class="text-sm text-naturals-n14">First Boot</h3>
162162
<p v-if="formState.hardwareType === 'metal'">
@@ -189,7 +189,7 @@ const installerImage = computed(() =>
189189
<code class="whitespace-wrap rounded bg-naturals-n4 px-2 py-1 wrap-anywhere">
190190
{{ link }}
191191
</code>
192-
<CopyButton :text="link" />
192+
<CopyButton :aria-label="`Copy ${label} link`" :text="link" />
193193
</dd>
194194

195195
<dd v-else>
@@ -213,7 +213,10 @@ const installerImage = computed(() =>
213213
following installer image to the machine configuration:
214214
</p>
215215

216-
<CodeBlock :code="installerImage" />
216+
<CodeBlock
217+
:button-attrs="{ 'aria-label': 'Copy create Talos test cluster command' }"
218+
:code="installerImage"
219+
/>
217220
</template>
218221

219222
<template v-if="formState.talosVersion && gte(formState.talosVersion, '1.12.0-alpha.2')">
@@ -223,6 +226,7 @@ const installerImage = computed(() =>
223226
Linux, run:
224227
</p>
225228
<CodeBlock
229+
:button-attrs="{ 'aria-label': 'Copy create Talos test cluster command' }"
226230
:code="`talosctl cluster create qemu --schematic-id=${schematic.id} --talos-version=v${formState.talosVersion}`"
227231
/>
228232
</template>
@@ -241,6 +245,7 @@ const installerImage = computed(() =>
241245
with this schematic, run the following command on a host in the same subnet:
242246
</p>
243247
<CodeBlock
248+
:button-attrs="{ 'aria-label': 'Copy PXE booter docker run command' }"
244249
:code="`docker run --rm --network host ghcr.io/siderolabs/booter:v0.3.0 --talos-version=v${formState.talosVersion} --schematic-id=${schematic.id}`"
245250
/>
246251

0 commit comments

Comments
 (0)