@@ -9,7 +9,10 @@ import { render, screen, within } from "@testing-library/react";
99import { describe , expect , it , vi } from "vitest" ;
1010
1111import FlashPage from "@/app/flash/page" ;
12- import type { FirmwareReleaseDescriptor } from "@/lib/firmware-release" ;
12+ import {
13+ firmwareProfileDescriptors ,
14+ type FirmwareReleaseDescriptor ,
15+ } from "@/lib/firmware-release" ;
1316import * as firmwareSelection from "@/lib/firmware-release-selection" ;
1417import type { LocalFirmwarePreviewDescriptor } from "@/lib/local-firmware-preview" ;
1518import {
@@ -48,6 +51,164 @@ function hypotheticalQualifiedReleaseAtVersion(
4851 return release as unknown as FirmwareReleaseDescriptor ;
4952}
5053
54+ function fiveProfileV060Release (
55+ deployment : "candidate" | "public" ,
56+ ) : FirmwareReleaseDescriptor {
57+ const version = "0.6.0" ;
58+ return {
59+ deployment,
60+ accessControlled : deployment === "candidate" ,
61+ version,
62+ builtAt : "2026-08-21T00:00:00Z" ,
63+ hilStatus : deployment === "public" ? "passed" : "pending" ,
64+ releaseJson : {
65+ path : `/firmware/v${ version } /release.json` ,
66+ sha256 : "a" . repeat ( 64 ) ,
67+ } ,
68+ schemaPath : `/firmware/v${ version } /release.schema.json` ,
69+ recoveryPath : `/firmware/v${ version } /RECOVERY.md` ,
70+ profiles : firmwareProfileDescriptors ( version ) ,
71+ } as FirmwareReleaseDescriptor ;
72+ }
73+
74+ async function withSelectedRelease (
75+ release : FirmwareReleaseDescriptor ,
76+ run : ( ) => Promise < void > | void ,
77+ ) {
78+ const selectionRoot = await mkdtemp (
79+ join ( tmpdir ( ) , "pyble-flash-five-profile-selection-" ) ,
80+ ) ;
81+ const selectionFile = join ( selectionRoot , "selection.json" ) ;
82+ const previousSelection = process . env . PYBLE_FLASH_SELECTION_FILE ;
83+ process . env . PYBLE_FLASH_SELECTION_FILE = selectionFile ;
84+ try {
85+ await writeFile ( selectionFile , JSON . stringify ( release ) , "utf8" ) ;
86+ await run ( ) ;
87+ } finally {
88+ if ( previousSelection === undefined ) {
89+ delete process . env . PYBLE_FLASH_SELECTION_FILE ;
90+ } else {
91+ process . env . PYBLE_FLASH_SELECTION_FILE = previousSelection ;
92+ }
93+ await rm ( selectionRoot , { recursive : true , force : true } ) ;
94+ }
95+ }
96+
97+ function expectFiveProfileFlashPageCopy ( ) {
98+ // The stale planned/unavailable cards must never render beside a
99+ // five-profile installer that offers those same targets.
100+ expect (
101+ screen . queryByRole ( "heading" , { name : / p l a n n e d p r o f i l e s / i } ) ,
102+ ) . toBeNull ( ) ;
103+ expect (
104+ screen . queryByText (
105+ / n o i n s t a l l e r s e l e c t i o n , f i r m w a r e i m a g e , o r r e c o v e r y c o m m a n d i s o f f e r e d y e t / i,
106+ ) ,
107+ ) . toBeNull ( ) ;
108+ expect ( screen . queryByText ( / p r e - G P 2 / i) ) . toBeNull ( ) ;
109+ expect (
110+ screen . queryByText ( / n o p u b l i c U F 2 d o w n l o a d o r i n s t a l l e r a c t i o n / i) ,
111+ ) . toBeNull ( ) ;
112+
113+ // Accurate five-target provisioning summary instead.
114+ const methods = screen
115+ . getByRole ( "heading" , { name : / t w o p r o v i s i o n i n g m e t h o d s / i } )
116+ . closest ( "section" ) ;
117+ expect ( methods ) . toBeDefined ( ) ;
118+ expect ( methods ) . toHaveTextContent (
119+ / E S P W e b T o o l s .* W e b S e r i a l .* f o u r e x a c t E S P p r o f i l e s / i,
120+ ) ;
121+ expect ( methods ) . toHaveTextContent (
122+ / e s p 3 2 - 4 m b .* e s p 3 2 - s 3 - n 1 6 r 8 .* w a v e s h a r e - e s p 3 2 - s 3 - l c d - 1 4 7 b .* e s p 3 2 - c 3 - 4 m b / i,
123+ ) ;
124+ expect ( methods ) . toHaveTextContent ( / P i c o 2 W .* B O O T S E L .* U F 2 / i) ;
125+
126+ // The profile explainer covers all five profiles with exact requirements.
127+ const profileSection = screen
128+ . getByRole ( "heading" , { name : / c h o o s e t h e e x a c t m o d u l e p r o f i l e / i } )
129+ . closest ( "section" ) ;
130+ expect ( profileSection ) . toBeDefined ( ) ;
131+ const classicProfile = within ( profileSection as HTMLElement )
132+ . getByText ( "esp32-4mb" )
133+ . closest ( "li" ) ;
134+ expect ( classicProfile ) . toHaveTextContent ( / C l a s s i c E S P 3 2 m o d u l e / ) ;
135+ const c3Profile = within ( profileSection as HTMLElement )
136+ . getByText ( "esp32-c3-4mb" )
137+ . closest ( "li" ) ;
138+ expect ( c3Profile ) . toHaveTextContent ( / r e v i s i o n v 0 \. 3 o r n e w e r .* 4 M i B f l a s h / i) ;
139+ const waveshareProfile = within ( profileSection as HTMLElement )
140+ . getByText ( "waveshare-esp32-s3-lcd-147b" )
141+ . closest ( "li" ) ;
142+ expect ( waveshareProfile ) . toHaveTextContent (
143+ / B v e r s i o n .* 1 6 M i B f l a s h .* 8 M i B O c t a l P S R A M / i,
144+ ) ;
145+ const picoProfile = within ( profileSection as HTMLElement )
146+ . getByText ( "rpi-pico2-w" )
147+ . closest ( "li" ) ;
148+ expect ( picoProfile ) . toHaveTextContent ( / R P 2 3 5 0 .* C Y W 4 3 4 3 9 / i) ;
149+ expect ( picoProfile ) . toHaveTextContent ( / B O O T S E L / i) ;
150+ expect ( picoProfile ) . toHaveTextContent ( / n o t a W e b S e r i a l f l o w / i) ;
151+
152+ // "Before you connect" must not claim Web Serial or desktop Chromium as a
153+ // requirement of the Pico flow.
154+ const beforeConnect = screen
155+ . getByRole ( "heading" , { name : / b e f o r e y o u c o n n e c t / i } )
156+ . closest ( "section" ) ;
157+ expect ( beforeConnect ) . toBeDefined ( ) ;
158+ expect ( beforeConnect ) . toHaveTextContent (
159+ / E S P .* W e b S e r i a l .* d e s k t o p C h r o m i u m | d e s k t o p C h r o m i u m .* W e b S e r i a l / i,
160+ ) ;
161+ expect ( beforeConnect ) . toHaveTextContent ( / P i c o 2 W d o e s n o t u s e W e b S e r i a l / i) ;
162+ expect ( beforeConnect ) . toHaveTextContent ( / s e c u r e c o n t e x t .* W e b C r y p t o / i) ;
163+ expect ( beforeConnect ) . not . toHaveTextContent (
164+ / T h e i n s t a l l e r u s e s W e b S e r i a l a n d W e b C r y p t o / i,
165+ ) ;
166+
167+ // The visible recovery section carries all four ESP merged-image commands.
168+ const recoverySection = screen
169+ . getByRole ( "heading" , { name : / r e c o v e r a n i n t e r r u p t e d f l a s h / i } )
170+ . closest ( "section" ) ;
171+ expect ( recoverySection ) . toBeDefined ( ) ;
172+ const recoveryCommands = [
173+ "python -m esptool --chip esp32 write_flash 0x1000 esp32-4mb/firmware.bin" ,
174+ "python -m esptool --chip esp32s3 write_flash 0x0 esp32-s3-n16r8/firmware.bin" ,
175+ "python -m esptool --chip esp32s3 write_flash 0x0 waveshare-esp32-s3-lcd-147b/firmware.bin" ,
176+ "python -m esptool --chip esp32c3 write_flash 0x0 esp32-c3-4mb/firmware.bin" ,
177+ ] ;
178+ for ( const command of recoveryCommands ) {
179+ expect (
180+ screen . getByText (
181+ ( _content , element ) =>
182+ element ?. tagName === "CODE" &&
183+ element . textContent ?. replace ( / \s + / g, " " ) . trim ( ) === command ,
184+ ) ,
185+ ) . toBeInTheDocument ( ) ;
186+ }
187+
188+ // Component offsets name the C3 beside the S3.
189+ expect ( recoverySection ) . toHaveTextContent (
190+ / b o o t l o a d e r a t 0 x 1 0 0 0 f o r c l a s s i c E S P 3 2 o r 0 x 0 f o r E S P 3 2 - S 3 a n d E S P 3 2 - C 3 / i,
191+ ) ;
192+
193+ // Separate Pico BOOTSEL re-copy recovery with no esptool/offset/Web Serial
194+ // fallback inside it.
195+ const picoRecovery = within ( recoverySection as HTMLElement )
196+ . getByText ( / P i c o 2 W r e c o v e r y / i)
197+ . closest ( "p" ) ;
198+ expect ( picoRecovery ) . toBeDefined ( ) ;
199+ expect ( picoRecovery ) . toHaveTextContent (
200+ / h o l d B O O T S E L w h i l e r e c o n n e c t i n g U S B / i,
201+ ) ;
202+ expect ( picoRecovery ) . toHaveTextContent ( / R P 2 3 5 0 v o l u m e / i) ;
203+ expect ( picoRecovery ) . toHaveTextContent ( / s a m e v e r s i o n e d U F 2 / i) ;
204+ expect ( picoRecovery ) . toHaveTextContent ( / a u t o m a t i c r e b o o t / i) ;
205+ expect ( picoRecovery ) . toHaveTextContent ( / P y B L E - X X X X / i) ;
206+ expect ( picoRecovery ) . toHaveTextContent (
207+ / d o e s n o t u s e W e b S e r i a l o r t h e E S P R O M r e c o v e r y c o n t r o l s / i,
208+ ) ;
209+ expect ( picoRecovery ?. textContent ) . not . toMatch ( / e s p t o o l | w r i t e _ f l a s h | 0 x [ 0 - 9 ] / i) ;
210+ }
211+
51212function localPreviewArtifact (
52213 path : string ,
53214 digestCharacter : string ,
@@ -217,6 +378,44 @@ describe("firmware installer release copy", () => {
217378 }
218379 } ) ;
219380
381+ it ( "describes the staged five-profile candidate accurately with pending-qualification framing" , async ( ) => {
382+ await withSelectedRelease ( fiveProfileV060Release ( "candidate" ) , ( ) => {
383+ render ( < FlashPage /> ) ;
384+
385+ expectFiveProfileFlashPageCopy ( ) ;
386+
387+ // Candidate mode alone carries the visibly pending candidate framing.
388+ expect (
389+ screen . getByText ( / a c c e s s - c o n t r o l l e d .* r e l e a s e c a n d i d a t e / i) ,
390+ ) . toBeInTheDocument ( ) ;
391+ const methods = screen
392+ . getByRole ( "heading" , { name : / t w o p r o v i s i o n i n g m e t h o d s / i } )
393+ . closest ( "section" ) ;
394+ expect ( methods ) . toHaveTextContent ( / v i s i b l y p e n d i n g c a n d i d a t e / i) ;
395+ expect ( methods ) . toHaveTextContent ( / h a r d w a r e q u a l i f i c a t i o n .* p e n d i n g / i) ;
396+ } ) ;
397+ } ) ;
398+
399+ it ( "covers all five targets in the qualified v0.6.0 release copy" , async ( ) => {
400+ await withSelectedRelease ( fiveProfileV060Release ( "public" ) , ( ) => {
401+ render ( < FlashPage /> ) ;
402+
403+ expect (
404+ screen . getByText (
405+ / q u a l i f i e d v 0 \. 6 \. 0 f i r m w a r e i s a v a i l a b l e f o r a l l f i v e e x a c t r e l e a s e p r o f i l e s / i,
406+ ) ,
407+ ) . toBeInTheDocument ( ) ;
408+
409+ expectFiveProfileFlashPageCopy ( ) ;
410+
411+ // Qualified-public mode never claims a pending candidate.
412+ const methods = screen
413+ . getByRole ( "heading" , { name : / t w o p r o v i s i o n i n g m e t h o d s / i } )
414+ . closest ( "section" ) ;
415+ expect ( methods ) . not . toHaveTextContent ( / v i s i b l y p e n d i n g c a n d i d a t e / i) ;
416+ } ) ;
417+ } ) ;
418+
220419 it ( "keeps the historical public beta available without a current exact-board claim" , async ( ) => {
221420 const selectionRoot = await mkdtemp (
222421 join ( tmpdir ( ) , "pyble-flash-qualified-selection-" ) ,
@@ -328,6 +527,8 @@ describe("firmware installer release copy", () => {
328527 expect ( boardSection ) . toHaveTextContent ( / 1 7 2 .* 3 2 0 .* S T 7 7 8 9 V 3 / i) ;
329528 expect ( boardSection ) . toHaveTextContent ( / p y b l e _ s t 7 7 8 9 / i) ;
330529 expect ( boardSection ) . toHaveTextContent ( / p y b l e _ w a v e s h a r e _ l c d 1 4 7 b / i) ;
530+ expect ( boardSection ) . toHaveTextContent ( / B l o c k l y T F T p r o g r a m s / ) ;
531+ expect ( boardSection ) . not . toHaveTextContent ( / B l o c k y T F T / ) ;
331532 expect ( boardSection ) . toHaveTextContent (
332533 / f r e s h e r a s e d i n s t a l l a t i o n .* b o o t s p l a s h .* s h o w n b y d e f a u l t .* p e r s i s t e n t l y d i s a b l e / i,
333534 ) ;
0 commit comments