diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9c0dfad2d..883dc4d3f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -200,6 +200,11 @@ jobs: if: github.event.pull_request.draft == false name: Playwright Tests runs-on: macos-latest + strategy: + matrix: + platform: + - { toolchain: 1.86.0 } + - { toolchain: beta } steps: # Do our best to cache the toolchain and node install steps - uses: actions/checkout@v4 @@ -207,12 +212,13 @@ jobs: with: node-version: 16 - name: Install Rust - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@master with: - toolchain: stable + toolchain: ${{ matrix.platform.toolchain }} targets: x86_64-unknown-linux-gnu,wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: + key: "playwright-${{ matrix.platform.toolchain }}-${{ runner.os }}" cache-all-crates: "true" cache-on-failure: "true" - name: Wipe dx cache @@ -220,6 +226,9 @@ jobs: rm -rf ./target/dx - name: Playwright working-directory: ./packages/playwright-tests + env: + # The hot patch test requires incremental compilation + CARGO_INCREMENTAL: 1 run: | npm ci npm install -D @playwright/test @@ -228,7 +237,7 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: playwright-report + name: playwright-report-${{ matrix.platform.toolchain }}-${{ runner.os }} path: ./packages/playwright-tests/playwright-report/ retention-days: 30 diff --git a/.gitignore b/.gitignore index a1737042a..df4b5192c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /packages/playwright-tests/web/dist /packages/playwright-tests/fullstack/dist /packages/playwright-tests/test-results +/packages/playwright-tests/web-hot-patch-temp /dist .DS_Store /examples/assets/test_video.mp4 diff --git a/packages/cli/src/build/assets.rs b/packages/cli/src/build/assets.rs index b549cda00..96faf4d04 100644 --- a/packages/cli/src/build/assets.rs +++ b/packages/cli/src/build/assets.rs @@ -41,7 +41,6 @@ use manganis::BundledAsset; use object::{File, Object, ObjectSection, ObjectSymbol, ReadCache, ReadRef, Section, Symbol}; use pdb::FallibleIterator; use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator}; -use wasmparser::BinaryReader; /// Extract all manganis symbols and their sections from the given object file. fn manganis_symbols<'a, 'b, R: ReadRef<'a>>( @@ -50,7 +49,7 @@ fn manganis_symbols<'a, 'b, R: ReadRef<'a>>( file.symbols() .filter(|symbol| { if let Ok(name) = symbol.name() { - name.contains("__MANGANIS__") + looks_like_manganis_symbol(name) } else { false } @@ -62,6 +61,10 @@ fn manganis_symbols<'a, 'b, R: ReadRef<'a>>( }) } +fn looks_like_manganis_symbol(name: &str) -> bool { + name.contains("__MANGANIS__") +} + /// Find the offsets of any manganis symbols in the given file. fn find_symbol_offsets<'a, R: ReadRef<'a>>( path: &Path, @@ -175,105 +178,113 @@ fn find_native_symbol_offsets<'a, R: ReadRef<'a>>(file: &File<'a, R>) -> Result< Ok(offsets) } +fn eval_walrus_global_expr(module: &walrus::Module, expr: &walrus::ConstExpr) -> Option { + match expr { + walrus::ConstExpr::Value(walrus::ir::Value::I32(value)) => Some(*value as u64), + walrus::ConstExpr::Value(walrus::ir::Value::I64(value)) => Some(*value as u64), + walrus::ConstExpr::Global(id) => { + let global = module.globals.get(*id); + if let walrus::GlobalKind::Local(pointer) = &global.kind { + eval_walrus_global_expr(module, pointer) + } else { + None + } + } + _ => None, + } +} + /// Find the offsets of any manganis symbols in the wasm file. fn find_wasm_symbol_offsets<'a, R: ReadRef<'a>>( file_contents: &[u8], file: &File<'a, R>, ) -> Result> { - // Parse the wasm file to find the globals - let parser = wasmparser::Parser::new(0); + let Some(section) = file + .sections() + .find(|section| section.name() == Ok("")) + else { + tracing::error!("Failed to find section in WASM file"); + return Ok(Vec::new()); + }; + let Some((_, section_range_end)) = section.file_range() else { + tracing::error!("Failed to find file range for section in WASM file"); + return Ok(Vec::new()); + }; + let section_size = section.data()?.len() as u64; + let section_start = section_range_end - section_size; - // All integer literal global values in the wasm file - let mut global_values = Vec::new(); - for section in parser.parse_all(file_contents) { - let Ok(wasmparser::Payload::GlobalSection(global_section)) = section else { + // Translate the section_relative_address to the file offset + // WASM files have a section address of 0 in object, reparse the data section with wasmparser + // to get the correct address and section start + // Note: We need to reparse just the data section with wasmparser to get the file offset because walrus does + // not expose the file offset information + let reader = wasmparser::DataSectionReader::new(wasmparser::BinaryReader::new( + &file_contents[section_start as usize..section_range_end as usize], + 0, + )) + .context("Failed to create WASM data section reader")?; + let main_memory = reader + .into_iter() + .next() + .context("Failed find main memory from WASM data section")? + .context("Failed to read main memory from WASM data section")?; + // main_memory.data is a slice somewhere in file_contents. Find out the offset in the file + let data_start_offset = (main_memory.data.as_ptr() as u64) + .checked_sub(file_contents.as_ptr() as u64) + .expect("Data section start offset should be within the file contents"); + + // Parse the wasm file to find the globals + let module = walrus::Module::from_buffer(file_contents).unwrap(); + let mut offsets = Vec::new(); + + // Find the main memory offset + let main_memory = module + .data + .iter() + .next() + .context("Failed to find main memory in WASM module")?; + + let walrus::DataKind::Active { + offset: main_memory_offset, + .. + } = main_memory.kind + else { + tracing::error!("Failed to find main memory offset in WASM module"); + return Ok(Vec::new()); + }; + + // In the hot patch build, the main memory offset is a global from the main module and each global + // is it's own global. Use an offset of 0 instead if we can't evaluate the global + let main_memory_offset = + eval_walrus_global_expr(&module, &main_memory_offset).unwrap_or_default(); + + for export in module.exports.iter() { + if !looks_like_manganis_symbol(&export.name) { + continue; + } + + let walrus::ExportItem::Global(global) = export.item else { continue; }; - global_values = global_section - .into_iter() - .map(|global| { - let global = global.ok()?; - match global.init_expr.get_operators_reader().into_iter().next() { - Some(Ok(wasmparser::Operator::I32Const { value })) => Some(value as u64), - Some(Ok(wasmparser::Operator::I64Const { value })) => Some(value as u64), - _ => None, - } - }) - .collect::>(); - } - let mut offsets = Vec::new(); + let walrus::GlobalKind::Local(pointer) = module.globals.get(global).kind else { + continue; + }; - for (symbol, section) in manganis_symbols(file) { - let virtual_address = symbol.address(); - - let Some((_, section_range_end)) = section.file_range() else { + let Some(virtual_address) = eval_walrus_global_expr(&module, &pointer) else { tracing::error!( - "Found __MANGANIS__ symbol {:?} in section {}, but the section has no file range", - symbol.name(), - section.index() + "Found __MANGANIS__ symbol {:?} in WASM file, but the global expression could not be evaluated", + export.name ); continue; }; - let section_size = section.data()?.len() as u64; - let section_start = section_range_end - section_size; - // Translate the section_relative_address to the file offset - // WASM files have a section address of 0 in object, reparse the data section with wasmparser - // to get the correct address and section start - let reader = wasmparser::DataSectionReader::new(BinaryReader::new( - &file_contents[section_start as usize..section_range_end as usize], - 0, - )) - .context("Failed to create WASM data section reader")?; - let main_memory = reader - .into_iter() - .next() - .context("Failed find main memory from WASM data section")? - .context("Failed to read main memory from WASM data section")?; - let main_memory_offset = match main_memory.kind { - wasmparser::DataKind::Active { offset_expr, .. } => { - match offset_expr.get_operators_reader().into_iter().next() { - Some(Ok(wasmparser::Operator::I32Const { value })) => -value as i128, - Some(Ok(wasmparser::Operator::I64Const { value })) => -value as i128, - Some(Ok(wasmparser::Operator::GlobalGet { global_index })) => { - let Some(value) = - global_values.get(global_index as usize).copied().flatten() - else { - tracing::error!( - "Found __MANGANIS__ symbol {:?} in WASM file, but the global index {} is not found", - symbol.name(), - global_index - ); - continue; - }; - value as i128 - } - offset_expr => { - tracing::error!( - "Found __MANGANIS__ symbol {:?} in WASM file, but the offset expression is not a constant is is {:?}", - symbol.name(), - offset_expr - ); - continue; - } - } - } - _ => { - tracing::error!( - "Found __MANGANIS__ symbol {:?} in WASM file, but the data section is not active", - symbol.name() - ); - continue; - } - }; - // main_memory.data is a slice somewhere in file_contents. Find out the offset in the file - let data_start_offset = (main_memory.data.as_ptr() as u64) - .checked_sub(file_contents.as_ptr() as u64) - .expect("Data section start offset should be within the file contents"); - let section_relative_address: u64 = ((virtual_address as i128) + main_memory_offset) + + let section_relative_address: u64 = ((virtual_address as i128) + - main_memory_offset as i128) .try_into() .expect("Virtual address should be greater than or equal to section address"); let file_offset = data_start_offset + section_relative_address; + offsets.push(file_offset); } @@ -304,6 +315,7 @@ pub(crate) fn extract_assets_from_file(path: impl AsRef) -> Result) -> Result) -> Result Result, diff --git a/packages/cli/src/serve/mod.rs b/packages/cli/src/serve/mod.rs index 7755410ff..ecc22e4ca 100644 --- a/packages/cli/src/serve/mod.rs +++ b/packages/cli/src/serve/mod.rs @@ -1,6 +1,7 @@ use crate::{ styles::{GLOW_STYLE, LINK_STYLE}, - AppBuilder, BuildId, BuildMode, BuilderUpdate, Platform, Result, ServeArgs, TraceController, + AppBuilder, BuildId, BuildMode, BuilderUpdate, Error, Platform, Result, ServeArgs, + TraceController, }; mod ansi_buffer; @@ -11,6 +12,7 @@ mod runner; mod server; mod update; +use dioxus_dx_wire_format::BuildStage; pub(crate) use output::*; pub(crate) use runner::*; pub(crate) use server::*; @@ -38,6 +40,7 @@ pub(crate) use update::*; /// to a dynamic one on the fly. pub(crate) async fn serve_all(args: ServeArgs, tracer: &mut TraceController) -> Result<()> { // Load the args into a plan, resolving all tooling, build dirs, arguments, decoding the multi-target, etc + let exit_on_error = args.exit_on_error; let mut builder = AppServer::start(args).await?; let mut devserver = WebServer::start(&builder)?; let mut screen = Output::start(builder.interactive).await?; @@ -138,12 +141,33 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &mut TraceController) -> // And then open the app if it's ready match update { + BuilderUpdate::Progress { + stage: BuildStage::Failed, + } => { + if exit_on_error { + return Err(Error::Cargo(format!( + "Build failed for platform: {platform}" + ))); + } + } + BuilderUpdate::Progress { + stage: BuildStage::Aborted, + } => { + if exit_on_error { + return Err(Error::Cargo(format!( + "Build aborted for platform: {platform}" + ))); + } + } BuilderUpdate::Progress { .. } => {} BuilderUpdate::CompilerMessage { message } => { screen.push_cargo_log(message); } BuilderUpdate::BuildFailed { err } => { tracing::error!("Build failed: {}", err); + if exit_on_error { + return Err(err); + } } BuilderUpdate::BuildReady { bundle } => match bundle.mode { BuildMode::Thin { ref cache, .. } => { @@ -191,12 +215,20 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &mut TraceController) -> ); } else { tracing::error!("Application [{platform}] exited with error: {status}"); + if exit_on_error { + return Err(Error::Runtime(format!( + "Application [{platform}] exited with error: {status}" + ))); + } } } BuilderUpdate::ProcessWaitFailed { err } => { tracing::warn!( "Failed to wait for process - maybe it's hung or being debugged?: {err}" ); + if exit_on_error { + return Err(err.into()); + } } } } diff --git a/packages/manganis/manganis-core/src/asset.rs b/packages/manganis/manganis-core/src/asset.rs index a613a97e8..531c7b345 100644 --- a/packages/manganis/manganis-core/src/asset.rs +++ b/packages/manganis/manganis-core/src/asset.rs @@ -127,13 +127,15 @@ impl BundledAsset { /// ``` #[derive(PartialEq, Clone, Copy)] pub struct Asset { - /// A pointer to the bundled asset. This will be resolved after the linker has run and - /// put into the lazy asset + /// A function that returns a pointer to the bundled asset. This will be resolved after the linker has run and + /// put into the lazy asset. We use a function instead of using the pointer directly to force the compiler to + /// read the static __REFERENCE_TO_LINK_SECTION at runtime which will be offset by the hot reloading engine instead + /// of at compile time which can't be offset /// /// WARNING: Don't read this directly. Reads can get optimized away at compile time before /// the data for this is filled in by the CLI after the binary is built. Instead, use /// [`std::ptr::read_volatile`] to read the data. - bundled: &'static [u8], + bundled: fn() -> &'static [u8], } impl Debug for Asset { @@ -149,14 +151,15 @@ impl Asset { #[doc(hidden)] /// This should only be called from the macro /// Create a new asset from the bundled form of the asset and the link section - pub const fn new(bundled: &'static [u8]) -> Self { + pub const fn new(bundled: fn() -> &'static [u8]) -> Self { Self { bundled } } /// Get the bundled asset pub fn bundled(&self) -> BundledAsset { - let len = self.bundled.len(); - let ptr = self.bundled as *const [u8] as *const u8; + let bundled = (self.bundled)(); + let len = bundled.len(); + let ptr = bundled as *const [u8] as *const u8; if ptr.is_null() { panic!("Tried to use an asset that was not bundled. Make sure you are compiling dx as the linker"); } @@ -164,7 +167,8 @@ impl Asset { for byte in 0..len { // SAFETY: We checked that the pointer was not null above. The pointer is valid for reads and // since we are reading a u8 there are no alignment requirements - bytes = bytes.push(unsafe { std::ptr::read_volatile(ptr.add(byte)) }); + let byte = unsafe { std::ptr::read_volatile(ptr.add(byte)) }; + bytes = bytes.push(byte); } let read = bytes.read(); deserialize_const!(BundledAsset, read).expect("Failed to deserialize asset. Make sure you built with the matching version of the Dioxus CLI").1 diff --git a/packages/manganis/manganis-macro/src/asset.rs b/packages/manganis/manganis-macro/src/asset.rs index c620c0c48..112566352 100644 --- a/packages/manganis/manganis-macro/src/asset.rs +++ b/packages/manganis/manganis-macro/src/asset.rs @@ -120,7 +120,7 @@ impl ToTokens for AssetParser { static __REFERENCE_TO_LINK_SECTION: &'static [u8] = &__LINK_SECTION; manganis::Asset::new( - __REFERENCE_TO_LINK_SECTION + || __REFERENCE_TO_LINK_SECTION ) } }) diff --git a/packages/playwright-tests/package-lock.json b/packages/playwright-tests/package-lock.json index 4d785ba86..69bd204d0 100644 --- a/packages/playwright-tests/package-lock.json +++ b/packages/playwright-tests/package-lock.json @@ -9,17 +9,17 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.52.0" + "@playwright/test": "^1.53.1" } }, "node_modules/@playwright/test": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", - "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.1.tgz", + "integrity": "sha512-Z4c23LHV0muZ8hfv4jw6HngPJkbbtZxTkxPNIg7cJcTc9C28N/p2q7g3JZS2SiKBBHJ3uM1dgDye66bB7LEk5w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.52.0" + "playwright": "1.53.1" }, "bin": { "playwright": "cli.js" @@ -44,13 +44,13 @@ } }, "node_modules/playwright": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", - "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.1.tgz", + "integrity": "sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.52.0" + "playwright-core": "1.53.1" }, "bin": { "playwright": "cli.js" @@ -63,9 +63,9 @@ } }, "node_modules/playwright-core": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", - "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.1.tgz", + "integrity": "sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/packages/playwright-tests/package.json b/packages/playwright-tests/package.json index 5f2b23a8d..595cfc4c6 100644 --- a/packages/playwright-tests/package.json +++ b/packages/playwright-tests/package.json @@ -12,6 +12,6 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.52.0" + "@playwright/test": "^1.53.1" } } diff --git a/packages/playwright-tests/playwright.config.js b/packages/playwright-tests/playwright.config.js index 9e445f82f..1c6074261 100644 --- a/packages/playwright-tests/playwright.config.js +++ b/packages/playwright-tests/playwright.config.js @@ -29,7 +29,7 @@ module.exports = defineConfig({ // baseURL: 'http://127.0.0.1:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", + trace: "retain-on-failure", // Increase the timeout for navigations to give dx time to build the project navigationTimeout: 50 * 60 * 1000, }, @@ -202,5 +202,13 @@ module.exports = defineConfig({ reuseExistingServer: !process.env.CI, stdout: "pipe", }, + { + command: + 'rm -rf ./web-hot-patch-temp && cp -r ./web-hot-patch ./web-hot-patch-temp && cd web-hot-patch-temp && cargo run --manifest-path ../../cli/Cargo.toml --release -- serve --verbose --force-sequential --platform web --addr "127.0.0.1" --port 9980 --hot-patch --exit-on-error', + port: 9980, + timeout: 50 * 60 * 1000, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + }, ], }); diff --git a/packages/playwright-tests/web-hot-patch/.gitignore b/packages/playwright-tests/web-hot-patch/.gitignore new file mode 100644 index 000000000..283c01d61 --- /dev/null +++ b/packages/playwright-tests/web-hot-patch/.gitignore @@ -0,0 +1,2 @@ +dist +target diff --git a/packages/playwright-tests/web-hot-patch/Cargo.toml b/packages/playwright-tests/web-hot-patch/Cargo.toml new file mode 100644 index 000000000..bcfeee4a5 --- /dev/null +++ b/packages/playwright-tests/web-hot-patch/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "dioxus-playwright-web-patch-test" +version = "0.0.1" +edition = "2021" +description = "Playwright test for Dioxus Web" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { path = "../../dioxus", features = ["web"]} + +[workspace] \ No newline at end of file diff --git a/packages/playwright-tests/web-hot-patch/assets/alternative-style.css b/packages/playwright-tests/web-hot-patch/assets/alternative-style.css new file mode 100644 index 000000000..bd621de2d --- /dev/null +++ b/packages/playwright-tests/web-hot-patch/assets/alternative-style.css @@ -0,0 +1,3 @@ +body { + background-color: rgb(100, 100, 100); +} diff --git a/packages/playwright-tests/web-hot-patch/assets/style.css b/packages/playwright-tests/web-hot-patch/assets/style.css new file mode 100644 index 000000000..8dcca08c7 --- /dev/null +++ b/packages/playwright-tests/web-hot-patch/assets/style.css @@ -0,0 +1,3 @@ +#main { + background-color: red; +} diff --git a/packages/playwright-tests/web-hot-patch/assets/toasts.png b/packages/playwright-tests/web-hot-patch/assets/toasts.png new file mode 100644 index 000000000..382298f9c Binary files /dev/null and b/packages/playwright-tests/web-hot-patch/assets/toasts.png differ diff --git a/packages/playwright-tests/web-hot-patch/src/main.rs b/packages/playwright-tests/web-hot-patch/src/main.rs new file mode 100644 index 000000000..22a632abd --- /dev/null +++ b/packages/playwright-tests/web-hot-patch/src/main.rs @@ -0,0 +1,30 @@ +use dioxus::prelude::*; + +const CSS: Asset = asset!("/assets/style.css"); +const IMAGE: Asset = asset!("/assets/toasts.png"); + +fn app() -> Element { + let mut num = use_signal(|| 0); + + rsx! { + document::Link { + href: CSS, + rel: "stylesheet", + } + img { + id: "toasts", + src: IMAGE, + } + button { + id: "increment-button", + onclick: move |_| { + num += 1; + }, + "Click me! Count: {num}" + } + } +} + +fn main() { + dioxus::launch(app); +} diff --git a/packages/playwright-tests/web-patch.spec.js b/packages/playwright-tests/web-patch.spec.js new file mode 100644 index 000000000..9f91638ca --- /dev/null +++ b/packages/playwright-tests/web-patch.spec.js @@ -0,0 +1,102 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); + +const hotPatchTimeout = { + timeout: 1000 * 60 * 2, // 2 minute +}; + +test("button click", async ({ page }) => { + const fs = require("fs"); + const mainPath = "web-hot-patch-temp/src/main.rs"; + var mainContent = fs.readFileSync(mainPath, "utf8"); + const stylePath = "web-hot-patch-temp/assets/style.css"; + var styleContent = fs.readFileSync(stylePath, "utf8"); + + // Reset any changes made to the main.rs and style.css files. + mainContent = mainContent.replace(/num \+= 2;/g, "num += 1;"); + mainContent = mainContent.replace("Click button! Count:", "Click me! Count:"); + mainContent = mainContent.replace("asset!('/assets/alternative-style.css')", "asset!('/assets/style.css')"); + fs.writeFileSync(mainPath, mainContent); + styleContent = styleContent.replace( + "background-color: blue;", + "background-color: red;" + ); + fs.writeFileSync(stylePath, styleContent); + + await page.goto("http://localhost:9980"); + + // ** First test make sure the initial fat build is working ** + // Expect the page to contain the counter text. + const main = page.locator("#main"); + await expect(main).toContainText("Click me! Count: 0"); + + // Click the increment button. + let button = page.locator("button#increment-button"); + await button.click(); + + // Expect the page to contain the updated counter text. + await expect(main).toContainText("Click me! Count: 1"); + + // Make sure the css is applied correctly. + await expect(main).toHaveCSS("background-color", "rgb(255, 0, 0)"); + + // Make sure the image is loaded. + const headerImage = page.locator("#toasts"); + // expect the attribute src to start with /assets/toasts- + await expect(headerImage).toHaveAttribute("src", /\/assets\/toasts-/); + + // ** Then make sure the hot patch is working ** + // Then change the file to increment by 2. + const updatedContent = mainContent.replace(/num \+= 1;/g, "num += 2;"); + // Change the click me text to reflect the new increment. + const updatedContentWithText = updatedContent.replace( + "Click me! Count:", + "Click button! Count:" + ); + fs.writeFileSync(mainPath, updatedContentWithText); + + // Wait for the page to update and show the new text. + await expect(main).toContainText("Click button! Count: 1", hotPatchTimeout); + + // Now click the button again. + await button.click(); + + // Expect the count to update by 2. + await expect(main).toContainText("Click button! Count: 3"); + + // Next change just the css file to change the background color to blue. + const updatedStyleContent = styleContent.replace( + "background-color: red;", + "background-color: blue;" + ); + fs.writeFileSync(stylePath, updatedStyleContent); + + // Wait for the page to update the background color. + await expect(main).toHaveCSS( + "background-color", + "rgb(0, 0, 255)", + hotPatchTimeout + ); + + // Make sure the image is still loaded. + // expect the attribute src to start with /assets/toasts- + await expect(headerImage).toHaveAttribute("src", /\/assets\/toasts-/); + + // ** Then add a new asset to the page ** + // Switch from style to alternative-style + const updatedContentWithAlternativeStyle = updatedContentWithText.replace( + 'asset!("/assets/style.css")', + 'asset!("/assets/alternative-style.css")' + ); + fs.writeFileSync(mainPath, updatedContentWithAlternativeStyle); + + // Assert the page has the new alternative style applied. + const body = page.locator("body"); + // Log the page content to debug if needed. + console.log(await page.content()); + await expect(body).toHaveCSS( + "background-color", + "rgb(100, 100, 100)", + hotPatchTimeout + ); +});