From 5eeb4c03c4bf53763aecfc2245b6393fac11ada2 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 18 Sep 2025 10:43:15 +0200 Subject: [PATCH] test: setup for skew protection e2e tests and first test (server actions case) --- package-lock.json | 37 ++ package.json | 2 + tests/e2e/skew-protection.test.ts | 574 ++++++++++++++++++ .../skew-protection/app/app-router/actions.js | 5 + .../app/app-router/linked/client-component.js | 12 + .../app/app-router/linked/page.js | 16 + .../skew-protection/app/app-router/page.js | 132 ++++ .../app/app-router/route-handler/route.js | 3 + .../dynamically-imported-module.js | 1 + .../app/dynamic-import/page.js | 40 ++ tests/fixtures/skew-protection/app/layout.js | 12 + .../app/middleware/[slug]/page.js | 16 + .../skew-protection/app/middleware/page.js | 125 ++++ .../app/next-config/[slug]/page.js | 18 + .../skew-protection/app/next-config/page.js | 56 ++ tests/fixtures/skew-protection/app/page.js | 30 + tests/fixtures/skew-protection/middleware.js | 32 + .../fixtures/skew-protection/next.config.mjs | 57 ++ tests/fixtures/skew-protection/package.json | 15 + .../skew-protection/pages/api/api-route.js | 3 + .../pages/pages-router/index.js | 119 ++++ .../pages-router/linked-getServerSideProps.js | 27 + .../pages-router/linked-getStaticProps.js | 24 + .../pages/pages-router/linked-static.js | 11 + .../skew-protection/public/local-image-b.png | Bin 0 -> 5894 bytes .../skew-protection/public/local-image.png | Bin 0 -> 6457 bytes .../skew-protection/public/variant-b.txt | 1 + .../skew-protection/public/variant.txt | 1 + .../skew-protection/test-variants.json | 9 + .../skew-protection/variant-config-b.mjs | 2 + .../skew-protection/variant-config.mjs | 2 + tests/utils/build-variants.mjs | 22 +- tests/utils/create-e2e-fixture.ts | 147 ++++- 33 files changed, 1542 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/skew-protection.test.ts create mode 100644 tests/fixtures/skew-protection/app/app-router/actions.js create mode 100644 tests/fixtures/skew-protection/app/app-router/linked/client-component.js create mode 100644 tests/fixtures/skew-protection/app/app-router/linked/page.js create mode 100644 tests/fixtures/skew-protection/app/app-router/page.js create mode 100644 tests/fixtures/skew-protection/app/app-router/route-handler/route.js create mode 100644 tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js create mode 100644 tests/fixtures/skew-protection/app/dynamic-import/page.js create mode 100644 tests/fixtures/skew-protection/app/layout.js create mode 100644 tests/fixtures/skew-protection/app/middleware/[slug]/page.js create mode 100644 tests/fixtures/skew-protection/app/middleware/page.js create mode 100644 tests/fixtures/skew-protection/app/next-config/[slug]/page.js create mode 100644 tests/fixtures/skew-protection/app/next-config/page.js create mode 100644 tests/fixtures/skew-protection/app/page.js create mode 100644 tests/fixtures/skew-protection/middleware.js create mode 100644 tests/fixtures/skew-protection/next.config.mjs create mode 100644 tests/fixtures/skew-protection/package.json create mode 100644 tests/fixtures/skew-protection/pages/api/api-route.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/index.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/linked-static.js create mode 100644 tests/fixtures/skew-protection/public/local-image-b.png create mode 100644 tests/fixtures/skew-protection/public/local-image.png create mode 100644 tests/fixtures/skew-protection/public/variant-b.txt create mode 100644 tests/fixtures/skew-protection/public/variant.txt create mode 100644 tests/fixtures/skew-protection/test-variants.json create mode 100644 tests/fixtures/skew-protection/variant-config-b.mjs create mode 100644 tests/fixtures/skew-protection/variant-config.mjs diff --git a/package-lock.json b/package-lock.json index 60c49d514a..87654a0ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,10 +22,12 @@ "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", @@ -5812,6 +5814,16 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -6605,6 +6617,16 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -36586,6 +36608,15 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -37168,6 +37199,12 @@ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true }, + "adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true + }, "agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", diff --git a/package.json b/package.json index 612bf394d7..c86a1c4706 100644 --- a/package.json +++ b/package.json @@ -65,10 +65,12 @@ "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", diff --git a/tests/e2e/skew-protection.test.ts b/tests/e2e/skew-protection.test.ts new file mode 100644 index 0000000000..dfec460313 --- /dev/null +++ b/tests/e2e/skew-protection.test.ts @@ -0,0 +1,574 @@ +import { expect } from '@playwright/test' +import { execaCommand } from 'execa' +import { + createE2EFixture, + createSite, + deleteSite, + getBuildFixtureVariantCommand, + publishDeploy, +} from '../utils/create-e2e-fixture.js' +import { test as baseTest } from '../utils/playwright-helpers.js' +import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' + +type ExtendedFixtures = { + skewProtection: { + siteId: string + url: string + deployA: Awaited> + deployB: Awaited> + } +} + +const test = baseTest.extend< + { prepareSkewProtectionScenario: (callback: () => T) => Promise }, + ExtendedFixtures +>({ + prepareSkewProtectionScenario: async ({ skewProtection }, use) => { + const fixture = async (callback: () => T) => { + // first we will publish deployA + // then we call arbitrary callback to allow tests to load page using deployA + // and after that we will publish deployB so page loaded in browser is not using + // currently published deploy anymore, but still get results from initially published deploy + + const pollURL = `${skewProtection.url}/variant.txt` + + await publishDeploy(skewProtection.siteId, skewProtection.deployA.deployID) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('A')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + const result = await callback() + + await publishDeploy(skewProtection.siteId, skewProtection.deployB.deployID) + + // https://netlify.slack.com/archives/C098NQ4DEF6/p1758207235732189 + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('B')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + return result + } + + await use(fixture) + }, + skewProtection: [ + async ({}, use) => { + const { siteId, url } = await createSite({ + name: `next-skew-tests-${Date.now()}`, + }) + + let onBuildStart: () => void = () => {} + const waitForBuildStart = new Promise((resolve) => { + onBuildStart = () => { + resolve() + } + }) + + const deployAPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + onBuildStart, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + }) + + // we don't have to wait for deployA to finish completely before starting deployB, but we do have to wait a little bit + // to at least when build starts building, as otherwise whole deploy might be skipped and only second deploy happens + await waitForBuildStart + + const deployBPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + onPreDeploy: async (fixtureRoot) => { + await execaCommand( + `${getBuildFixtureVariantCommand('variant-b')} --apply-file-changes-only`, + { + cwd: fixtureRoot, + }, + ) + }, + }) + + const [deployA, deployB] = await Promise.all([deployAPromise, deployBPromise]) + + const fixture = { + url, + siteId, + deployA, + deployB, + + cleanup: async () => { + if (process.env.E2E_PERSIST) { + console.log( + `💾 Fixture and deployed site have been persisted. To clean up automatically, run tests without the 'E2E_PERSIST' environment variable.`, + ) + + return + } + + await deployA.cleanup() + await deployB.cleanup() + await deleteSite(siteId) + }, + } + + // for local iteration - this will print out snippet to allow to reuse previously deployed setup + // paste this at the top of `skewProtection` fixture function and this will avoid having to wait for redeploys + // keep in mind that if fixture itself require changes, you will have to redeploy + // uncomment console.log if you want to use same site/fixture and just iterate on test themselves + // and run a test with E2E_PERSIST=1 to keep site around for future runs + if (process.env.E2E_PERSIST) { + console.log( + 'You can reuse persisted site by pasting below snippet at the top of `skewProtection` fixture logic', + ) + console.log(`await use(${JSON.stringify(fixture, null, 2)})\n\nreturn`) + } + await use(fixture) + + await fixture.cleanup() + }, + { + scope: 'worker', + }, + ], +}) + +test.describe('Skew Protection', () => { + test.describe('App Router', () => { + test('should scope next/link navigation to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + + // this tests that both RSC and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-linked-page"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-server-component-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-page-client-component-current-variant')).toHaveText( + '"A"', + ) + }) + + test('should scope server actions to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('server-action-button').click() + + const element = await page.waitForSelector('[data-testid="server-action-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be either "B" (currently published deploy) + // or error about not finding server action - example of such error: + // "Error: Server Action "00a130b1673301d79679b22abb06a62c3125376d79" was not found on the server. + // Read more: https://nextjs.org/docs/messages/failed-to-find-server-action" + expect(content).toBe(`"A"`) + }) + + test('should scope route handler to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('scoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-route-handler-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope route handler to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('unscoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-route-handler-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Pages Router', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('when linked page is fully static', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-fully-static"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-static-current-variant')).toHaveText('"A"') + }) + + test('when linked page is getStaticProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getStaticProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getStaticProps-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-getStaticProps-props-variant')).toHaveText('"A"') + }) + + test('when linked page is getServerSideProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getServerSideProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getServerSideProps-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-getServerSideProps-props-variant')).toHaveText('"A"') + }) + }) + + test('should scope api route to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('scoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-api-route-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope api route to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('unscoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-api-route-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Middleware', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('NextResponse.next()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-next"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('next') + }) + + test('NextResponse.redirect()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('NextResponse.rewrite()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test('should scope middleware endpoint to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('scoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="scoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope middleware endpoint to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('unscoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="unscoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Next.js config rewrite and redirects', () => { + test('should scope next/link navigation to initial deploy when link target is Next.js config redirect', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('should scope next/link navigation to initial deploy when link target is Next.js config rewrite', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test.describe('Dynamic import', () => { + test('should scope dynamic import to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/dynamic-import`) + }) + + page.getByTestId('dynamic-import-button').click() + + const element = await page.waitForSelector('[data-testid="dynamic-import-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + }) +}) diff --git a/tests/fixtures/skew-protection/app/app-router/actions.js b/tests/fixtures/skew-protection/app/app-router/actions.js new file mode 100644 index 0000000000..b7f7deb422 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/actions.js @@ -0,0 +1,5 @@ +'use server' + +export async function testAction() { + return process.env.SKEW_VARIANT +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/client-component.js b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js new file mode 100644 index 0000000000..2006d4f1ab --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js @@ -0,0 +1,12 @@ +'use client' + +export function ClientComponent() { + return ( +

+ Client Component - variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/page.js b/tests/fixtures/skew-protection/app/app-router/linked/page.js new file mode 100644 index 0000000000..8d8d10c8d5 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/page.js @@ -0,0 +1,16 @@ +import { ClientComponent } from './client-component' + +export default function Page() { + return ( + <> +

Skew Protection Testing - App Router - next/link navigation test

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ + + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/page.js b/tests/fixtures/skew-protection/app/app-router/page.js new file mode 100644 index 0000000000..b091fb2be2 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/page.js @@ -0,0 +1,132 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +import { testAction } from './actions' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [actionResult, setActionResult] = useState(null) + const [scopedRouteHandlerResult, setScopedRouteHandlerResult] = useState(null) + const [unscopedRouteHandlerResult, setUnscopedRouteHandlerResult] = useState(null) + + return ( + <> +

Skew Protection Testing - App Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+

Server Action

+
+ + {actionResult && ( +

+ Action result: {actionResult} ( + {actionResult === process.env.SKEW_VARIANT ? 'match' : 'mismatch'}) +

+ )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of route handler as one that served initial html to the browser + } +

Fetching route-handler (scoped)

+
+ + {scopedRouteHandlerResult && ( +

+ Scoped route handler result: + {scopedRouteHandlerResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of route handler + } +

Fetching route-handler (unscoped)

+
+ + {unscopedRouteHandlerResult && ( +

+ Unscoped route handler result: + {unscopedRouteHandlerResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/route-handler/route.js b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js new file mode 100644 index 0000000000..2a526c18a9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js @@ -0,0 +1,3 @@ +export const GET = async (req) => { + return new Response(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js new file mode 100644 index 0000000000..967763eda9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js @@ -0,0 +1 @@ +export const variant = process.env.SKEW_VARIANT diff --git a/tests/fixtures/skew-protection/app/dynamic-import/page.js b/tests/fixtures/skew-protection/app/dynamic-import/page.js new file mode 100644 index 0000000000..c1f9b68044 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/page.js @@ -0,0 +1,40 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [dynamicallyImportedValue, setDynamicallyImportedValue] = useState(null) + + return ( + <> +

Skew Protection Testing - Dynamic import

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

Dynamic import

+
+ + {dynamicallyImportedValue && ( +

+ Dynamic import result: + {dynamicallyImportedValue} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/layout.js b/tests/fixtures/skew-protection/app/layout.js new file mode 100644 index 0000000000..6565e7bafd --- /dev/null +++ b/tests/fixtures/skew-protection/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Simple Next App', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/[slug]/page.js b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js new file mode 100644 index 0000000000..9c5f4971ec --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js @@ -0,0 +1,16 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

Skew Protection Testing - Middleware - link target page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/page.js b/tests/fixtures/skew-protection/app/middleware/page.js new file mode 100644 index 0000000000..f8f57a53d7 --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/page.js @@ -0,0 +1,125 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [scopedMiddlewareEndpointResult, setScopedMiddlewareEndpointResult] = useState(null) + const [unscopedMiddlewareEndpointResult, setUnscopedMiddlewareEndpointResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Middleware

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of middleware endpoint as one that served initial html to the browser + } +

Fetching middleware endpoint (scoped)

+
+ + {scopedMiddlewareEndpointResult && ( +

+ Scoped middleware endpoint result: + + {scopedMiddlewareEndpointResult} + +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of middleware endpoint + } +

Fetching middleware endpoint (unscoped)

+
+ + {unscopedMiddlewareEndpointResult && ( +

+ Unscoped middleware endpoint result: + + {unscopedMiddlewareEndpointResult} + +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/[slug]/page.js b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js new file mode 100644 index 0000000000..2bf7c3ac85 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js @@ -0,0 +1,18 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

+ Skew Protection Testing - next.config.js - link target page +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/page.js b/tests/fixtures/skew-protection/app/next-config/page.js new file mode 100644 index 0000000000..883e8a1155 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/page.js @@ -0,0 +1,56 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + + return ( + <> +

+ Skew Protection Testing - next.config.js +

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/page.js b/tests/fixtures/skew-protection/app/page.js new file mode 100644 index 0000000000..831070adbd --- /dev/null +++ b/tests/fixtures/skew-protection/app/page.js @@ -0,0 +1,30 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing

+ + + ) +} diff --git a/tests/fixtures/skew-protection/middleware.js b/tests/fixtures/skew-protection/middleware.js new file mode 100644 index 0000000000..0b354a6d55 --- /dev/null +++ b/tests/fixtures/skew-protection/middleware.js @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export function middleware(request) { + const parsedVariant = JSON.parse(process.env.SKEW_VARIANT) + + if (request.nextUrl.pathname === '/middleware/next') { + return NextResponse.next() + } + + if (request.nextUrl.pathname === '/middleware/redirect') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/redirect-${parsedVariant.toLowerCase()}` + return NextResponse.redirect(url) + } + + if (request.nextUrl.pathname === '/middleware/rewrite') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/rewrite-${parsedVariant.toLowerCase()}` + return NextResponse.rewrite(url) + } + + if (request.nextUrl.pathname === '/middleware/json') { + return NextResponse.json(parsedVariant) + } +} + +export const config = { + matcher: '/middleware/:path*', +} diff --git a/tests/fixtures/skew-protection/next.config.mjs b/tests/fixtures/skew-protection/next.config.mjs new file mode 100644 index 0000000000..511d6fd2e5 --- /dev/null +++ b/tests/fixtures/skew-protection/next.config.mjs @@ -0,0 +1,57 @@ +import { remoteImage, variant } from './variant-config.mjs' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + // for next@<14.0.0 + serverActions: true, + // for next@<14.1.4 + useDeploymentId: true, + // Optionally, use with Server Actions + useDeploymentIdServerActions: true, + }, + outputFileTracingRoot: import.meta.dirname, + + // for next@<15.1.0 + webpack(config, { webpack }) { + config.plugins.push( + new webpack.DefinePlugin({ + // double JSON.stringify is intentional here - this is to keep results same as when using `compile.define` + 'process.env.SKEW_VARIANT': JSON.stringify(JSON.stringify(variant)), + }), + ) + return config + }, + + compiler: { + // this is same as above webpack config, but this will apply to turbopack builds as well + // so just future proofing it here + define: { + 'process.env.SKEW_VARIANT': JSON.stringify(variant), + }, + }, + + redirects() { + return [ + { + source: '/next-config/redirect', + destination: `/next-config/redirect-${variant.toLowerCase()}`, + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/next-config/rewrite', + destination: `/next-config/rewrite-${variant.toLowerCase()}`, + }, + ] + }, +} + +export default nextConfig diff --git a/tests/fixtures/skew-protection/package.json b/tests/fixtures/skew-protection/package.json new file mode 100644 index 0000000000..299855e023 --- /dev/null +++ b/tests/fixtures/skew-protection/package.json @@ -0,0 +1,15 @@ +{ + "name": "skew-protection", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "npm run build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/skew-protection/pages/api/api-route.js b/tests/fixtures/skew-protection/pages/api/api-route.js new file mode 100644 index 0000000000..7dc0f0c926 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/api/api-route.js @@ -0,0 +1,3 @@ +export default function handler(_req, res) { + res.send(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/index.js b/tests/fixtures/skew-protection/pages/pages-router/index.js new file mode 100644 index 0000000000..364da0cccb --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/index.js @@ -0,0 +1,119 @@ +import { useState } from 'react' +import Link from 'next/link' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [unscopedApiRouteResult, setUnscopedApiRouteResult] = useState(null) + const [scopedApiRouteResult, setScopedApiRouteResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Pages Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of api route as one that served initial html to the browser + } +

Fetching API route (scoped)

+
+ + {scopedApiRouteResult && ( +

+ Scoped API route result: + {scopedApiRouteResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of api route + } +

Fetching API route (unscoped)

+
+ + {unscopedApiRouteResult && ( +

+ Unscoped API route result: + {unscopedApiRouteResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js new file mode 100644 index 0000000000..26cc4cd94d --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js @@ -0,0 +1,27 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getServerSideProps +

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+

+ Variant from props:{' '} + {variant} +

+ + ) +} + +export async function getServerSideProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js new file mode 100644 index 0000000000..1e9e6dd46e --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js @@ -0,0 +1,24 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getStaticProps +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Variant from props: {variant} +

+ + ) +} + +export async function getStaticProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-static.js b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js new file mode 100644 index 0000000000..7eaaf5eff2 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js @@ -0,0 +1,11 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing - Pages Router - fully static page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/public/local-image-b.png b/tests/fixtures/skew-protection/public/local-image-b.png new file mode 100644 index 0000000000000000000000000000000000000000..e0e353318fc29ae73949ece8370d7ba99af3b276 GIT binary patch literal 5894 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/skew-protection/public/local-image.png b/tests/fixtures/skew-protection/public/local-image.png new file mode 100644 index 0000000000000000000000000000000000000000..a282ce91bb3188c56ec00f74eb8c766e318339cf GIT binary patch literal 6457 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 2 ? argv.slice(2) : Object.keys(variants) +const variantsToBuild = + argv.length > 2 ? argv.slice(2).filter((arg) => !arg.startsWith('--')) : Object.keys(variants) + +const flags = argv.slice(2).filter((arg) => arg.startsWith('--')) /** @type {string[]} */ const notExistingVariants = [] @@ -118,12 +121,6 @@ for (const variantToBuild of variantsToBuild) { } } - const buildCommand = variant.buildCommand ?? 'next build' - const distDir = variant.distDir ?? '.next' - console.warn( - `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, - ) - for (const [target, source] of Object.entries(variant.files ?? {})) { const targetBackup = `${target}.bak` // create backup @@ -139,6 +136,17 @@ for (const variantToBuild of variantsToBuild) { }) } + if (flags.includes('--apply-file-changes-only')) { + console.warn(`[build-variants] Applied file changes for ${variantToBuild} variant`) + continue + } + + const buildCommand = variant.buildCommand ?? 'next build' + const distDir = variant.distDir ?? '.next' + console.warn( + `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, + ) + const result = await execaCommand(buildCommand, { env: { ...process.env, diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index fe2546da2e..3eebaad704 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -1,3 +1,4 @@ +import AdmZip from 'adm-zip' import { execaCommand } from 'execa' import fg from 'fast-glob' import { exec } from 'node:child_process' @@ -45,6 +46,25 @@ interface E2EConfig { * Site ID to deploy to. Defaults to the `NETLIFY_SITE_ID` environment variable or a default site. */ siteId?: string + /** + * If set to true, instead of using CLI to deploy, we will zip the source files and trigger build from zip. + */ + useBuildbot?: boolean + /** + * Runs before deploying the site if defined. + */ + onPreDeploy?: (isolatedFixtureRoot: string) => Promise + /** + * Buildbot mode specific callback that will be called once the build starts. + * Useful for scenario of triggering multiple consecutive builds, to be able to schedule builds + * before previous one finish completely. If multiple builds are scheduled at the same time, some + * of them might be skipped and this callback allows to avoid this scenario. + */ + onBuildStart?: () => Promise | void + /** + * Environment variables that will be added to `netlify.toml` if set. + */ + env?: Record } /** @@ -80,6 +100,9 @@ export const createE2EFixture = async (fixture: string, config: E2EConfig = {}) await setNextVersionInFixture(isolatedFixtureRoot, NEXT_VERSION) await installRuntime(packageName, isolatedFixtureRoot, config) await verifyFixture(isolatedFixtureRoot, config) + await config.onPreDeploy?.(isolatedFixtureRoot) + + const deploySite = config.useBuildbot ? deploySiteWithBuildbot : deploySiteWithCLI const result = await deploySite(isolatedFixtureRoot, config) @@ -157,6 +180,13 @@ async function buildAndPackRuntime( `[build] command = "${buildCommand}" publish = "${publishDirectory ?? join(siteRelDir, '.next')}" +${ + config.env + ? `[build.environment]\n${Object.entries(config.env) + .map(([key, value]) => `${key} = "${value}"`) + .join('\n')}` + : '' +} [[plugins]] package = "${name}" @@ -260,7 +290,7 @@ async function verifyFixture(isolatedFixtureRoot: string, { expectedCliVersion } } } -async function deploySite( +export async function deploySiteWithCLI( isolatedFixtureRoot: string, { packagePath, cwd = '', siteId = SITE_ID }: E2EConfig, ): Promise { @@ -293,6 +323,91 @@ async function deploySite( } } +export async function deploySiteWithBuildbot( + isolatedFixtureRoot: string, + { packagePath, siteId = SITE_ID, publishDirectory = '.next', onBuildStart }: E2EConfig, +): Promise { + if (packagePath) { + // It's likely possible to support this, just skipping implementing it until there's a need + // throwing just to be explicit that this was not done to avoid potential confusion if things + // don't work + throw new Error('packagePath is not currently supported when deploying with buildbot') + } + + if (!process.env.NETLIFY_AUTH_TOKEN) { + // we use CLI (ntl api) for most of operations, but build zip upload seems impossible with CLI + // and we do need to use API directly and we do need token for that + throw new Error('NETLIFY_AUTH_TOKEN is required for buildbot deploy, but it was not set') + } + + console.log(`🚀 Packing source files and triggering deploy`) + + const newZip = new AdmZip() + newZip.addLocalFolder(isolatedFixtureRoot, '', (entry) => { + if ( + // don't include node_modules / .git / publish dir in zip + entry.startsWith('node_modules') || + entry.startsWith('.git') || + entry.startsWith(publishDirectory) + ) { + return false + } + return true + }) + + const result = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, { + method: 'POST', + headers: { + 'Content-Type': 'application/zip', + Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`, + }, + // @ts-expect-error sigh, it works + body: newZip.toBuffer(), + }) + const { deploy_id } = await result.json() + + let didRunOnBuildStartCallback = false + const runOnBuildStartCallbackOnce = onBuildStart + ? () => { + if (!didRunOnBuildStartCallback) { + didRunOnBuildStartCallback = true + return onBuildStart() + } + } + : () => {} + + // poll for status + while (true) { + const { stdout } = await execaCommand( + `npx netlify api getDeploy --data=${JSON.stringify({ deploy_id })}`, + ) + const { state } = JSON.parse(stdout) + + if (state === 'error' || state === 'rejected') { + await runOnBuildStartCallbackOnce() + throw new Error( + `The deploy failed https://app.netlify.com/projects/${siteId}/deploys/${deploy_id}`, + ) + } + if (state === 'ready') { + await runOnBuildStartCallbackOnce() + break + } + + if (state === 'building') { + await runOnBuildStartCallbackOnce() + } + + await new Promise((resolve) => setTimeout(resolve, 5000)) + } + + return { + deployID: deploy_id, + url: `https://${deploy_id}--${siteId}.netlify.app`, // this is not nice, but it does work + logs: '', + } +} + export async function deleteDeploy(deployID?: string): Promise { if (!deployID) { return @@ -312,10 +427,38 @@ async function cleanup(dest: string, deployId?: string): Promise { await Promise.allSettled([deleteDeploy(deployId), rm(dest, { recursive: true, force: true })]) } -function getBuildFixtureVariantCommand(variantName: string) { +export function getBuildFixtureVariantCommand(variantName: string) { return `node ${fileURLToPath(new URL(`./build-variants.mjs`, import.meta.url))} ${variantName}` } +export async function createSite(siteConfig?: { name: string }) { + const cmd = `npx netlify api createSiteInTeam --data=${JSON.stringify({ + account_slug: 'netlify-integration-testing', + body: siteConfig ?? {}, + })}` + + const { stdout } = await execaCommand(cmd) + const { site_id, ssl_url, admin_url } = JSON.parse(stdout) + + console.log(`🚀 Created site ${ssl_url} / ${admin_url}`) + + return { + siteId: site_id as string, + url: ssl_url as string, + adminUrl: admin_url as string, + } +} + +export async function deleteSite(siteId: string) { + const cmd = `npx netlify api deleteSite --data=${JSON.stringify({ site_id: siteId })}` + await execaCommand(cmd) +} + +export async function publishDeploy(siteId: string, deployID: string) { + const cmd = `npx netlify api restoreSiteDeploy --data=${JSON.stringify({ site_id: siteId, deploy_id: deployID })}` + await execaCommand(cmd) +} + export const fixtureFactories = { simple: () => createE2EFixture('simple'), helloWorldTurbopack: () =>