Skip to content

Commit 850cef7

Browse files
committed
test: setup for skew protection e2e tests and first test (server actions case)
1 parent f9fb5fa commit 850cef7

21 files changed

+716
-9
lines changed

package-lock.json

Lines changed: 37 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,12 @@
6565
"@netlify/zip-it-and-ship-it": "^14.1.7",
6666
"@opentelemetry/api": "^1.8.0",
6767
"@playwright/test": "^1.43.1",
68+
"@types/adm-zip": "^0.5.7",
6869
"@types/node": "^20.12.7",
6970
"@types/picomatch": "^3.0.0",
7071
"@types/uuid": "^10.0.0",
7172
"@vercel/nft": "^0.30.0",
73+
"adm-zip": "^0.5.16",
7274
"cheerio": "^1.0.0-rc.12",
7375
"clean-package": "^2.2.0",
7476
"esbuild": "^0.25.0",

tests/e2e/skew-protection.test.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { expect } from '@playwright/test'
2+
import { execaCommand } from 'execa'
3+
import {
4+
createE2EFixture,
5+
createSite,
6+
deleteSite,
7+
getBuildFixtureVariantCommand,
8+
publishDeploy,
9+
} from '../utils/create-e2e-fixture.js'
10+
import { test as baseTest } from '../utils/playwright-helpers.js'
11+
12+
type ExtendedFixtures = {
13+
skewProtection: {
14+
siteId: string
15+
url: string
16+
deployA: Awaited<ReturnType<typeof createE2EFixture>>
17+
deployB: Awaited<ReturnType<typeof createE2EFixture>>
18+
}
19+
}
20+
21+
const test = baseTest.extend<
22+
{ prepareSkewProtectionScenario: <T>(callback: () => T) => Promise<T> },
23+
ExtendedFixtures
24+
>({
25+
prepareSkewProtectionScenario: async ({ skewProtection }, use) => {
26+
const fixture = async <T>(callback: () => T) => {
27+
// first we will publish deployA
28+
// then we call arbitrary callback to allow tests to load page using deployA
29+
// and after that we will publish deployB so page loaded in browser is not using
30+
// currently published deploy anymore, but still get results from initially published deploy
31+
32+
const pollURL = `${skewProtection.url}/variant.txt`
33+
34+
await publishDeploy(skewProtection.siteId, skewProtection.deployA.deployID)
35+
36+
// poll to ensure deploy was restored before continuing
37+
while (true) {
38+
const response = await fetch(pollURL)
39+
const text = await response.text()
40+
if (text.startsWith('A')) {
41+
break
42+
}
43+
await new Promise((resolve) => setTimeout(resolve, 50))
44+
}
45+
46+
const result = await callback()
47+
48+
await publishDeploy(skewProtection.siteId, skewProtection.deployB.deployID)
49+
50+
// https://netlify.slack.com/archives/C098NQ4DEF6/p1758207235732189
51+
await new Promise((resolve) => setTimeout(resolve, 3000))
52+
53+
// poll to ensure deploy was restored before continuing
54+
while (true) {
55+
const response = await fetch(pollURL)
56+
const text = await response.text()
57+
if (text.startsWith('B')) {
58+
break
59+
}
60+
await new Promise((resolve) => setTimeout(resolve, 50))
61+
}
62+
63+
return result
64+
}
65+
66+
await use(fixture)
67+
},
68+
skewProtection: [
69+
async ({}, use) => {
70+
// await use({
71+
// url: 'https://next-skew-tests-1758217746427.netlify.app',
72+
// siteId: 'bab7700f-68d8-4096-bf81-45b5160815e3',
73+
// deployA: {
74+
// deployID: '68cc461bc9e8be219baf99b6',
75+
// url: 'https://68cc461bc9e8be219baf99b6--bab7700f-68d8-4096-bf81-45b5160815e3.netlify.app',
76+
// },
77+
// deployB: {
78+
// deployID: '68cc462f7f7210026ec7a60c',
79+
// url: 'https://68cc462f7f7210026ec7a60c--bab7700f-68d8-4096-bf81-45b5160815e3.netlify.app',
80+
// },
81+
// })
82+
83+
// return
84+
85+
const { siteId, url } = await createSite({
86+
name: `next-skew-tests-${Date.now()}`,
87+
})
88+
89+
let onBuildStart: () => void = () => {}
90+
const waitForBuildStart = new Promise<void>((resolve) => {
91+
onBuildStart = () => {
92+
resolve()
93+
}
94+
})
95+
96+
const deployAPromise = createE2EFixture('skew-protection', {
97+
siteId,
98+
useBuildbot: true,
99+
onBuildStart,
100+
env: {
101+
NETLIFY_NEXT_PLUGIN_SKEW_PROTECTION: 'true',
102+
},
103+
})
104+
105+
// we don't have to wait for deployA to finish completely before starting deployB, but we do have to wait a little bit
106+
// to at least when build starts building, as otherwise whole deploy might be skipped and only second deploy happens
107+
await waitForBuildStart
108+
109+
const deployBPromise = createE2EFixture('skew-protection', {
110+
siteId,
111+
useBuildbot: true,
112+
env: {
113+
NETLIFY_NEXT_PLUGIN_SKEW_PROTECTION: 'true',
114+
},
115+
onPreDeploy: async (fixtureRoot) => {
116+
await execaCommand(
117+
`${getBuildFixtureVariantCommand('variant-b')} --apply-file-changes-only`,
118+
{
119+
cwd: fixtureRoot,
120+
},
121+
)
122+
},
123+
})
124+
125+
const [deployA, deployB] = await Promise.all([deployAPromise, deployBPromise])
126+
127+
const fixture = {
128+
url,
129+
siteId,
130+
deployA,
131+
deployB,
132+
133+
cleanup: async () => {
134+
if (process.env.E2E_PERSIST) {
135+
console.log(
136+
`💾 Fixture and deployed site have been persisted. To clean up automatically, run tests without the 'E2E_PERSIST' environment variable.`,
137+
)
138+
139+
return
140+
}
141+
142+
await deployA.cleanup()
143+
await deployB.cleanup()
144+
await deleteSite(siteId)
145+
},
146+
}
147+
148+
// for local iteration - this will print out snippet to allow to reuse previously deployed setup
149+
// paste this at the top of `skewProtection` fixture function and this will avoid having to wait for redeploys
150+
// keep in mind that if fixture itself require changes, you will have to redeploy
151+
// uncomment console.log if you want to use same site/fixture and just iterate on test themselves
152+
// and run a test with E2E_PERSIST=1 to keep site around for future runs
153+
console.log(`await use(${JSON.stringify(fixture, null, 2)})\n\nreturn`)
154+
await use(fixture)
155+
156+
await fixture.cleanup()
157+
},
158+
{
159+
scope: 'worker',
160+
},
161+
],
162+
})
163+
164+
test.describe('Skew Protection', () => {
165+
test.describe('App Router', () => {
166+
test('should scope next/link navigation to initial deploy', async ({
167+
page,
168+
skewProtection,
169+
prepareSkewProtectionScenario,
170+
}) => {
171+
await prepareSkewProtectionScenario(async () => {
172+
return await page.goto(`${skewProtection.url}/app-router`)
173+
})
174+
175+
// now that other deploy was published, we can show links
176+
page.getByTestId('next-link-expand-button').click()
177+
178+
// wait for links to show
179+
const element = await page.waitForSelector('[data-testid="next-link-linked-page"]')
180+
element.click()
181+
182+
// ensure expected version of a page is rendered
183+
await expect(page.getByTestId('linked-page-server-component-current-variant')).toHaveText(
184+
'"A"',
185+
)
186+
await expect(page.getByTestId('linked-page-client-component-current-variant')).toHaveText(
187+
'"A"',
188+
)
189+
})
190+
191+
test('should scope server actions to initial deploy', async ({
192+
page,
193+
skewProtection,
194+
prepareSkewProtectionScenario,
195+
}) => {
196+
await prepareSkewProtectionScenario(async () => {
197+
return await page.goto(`${skewProtection.url}/app-router`)
198+
})
199+
200+
page.getByTestId('server-action-button').click()
201+
202+
const element = await page.waitForSelector('[data-testid="server-action-result"]')
203+
const content = await element.textContent()
204+
205+
// if skew protection does not work, this will be either "B" (currently published deploy)
206+
// or error about not finding server action - example of such error:
207+
// "Error: Server Action "00a130b1673301d79679b22abb06a62c3125376d79" was not found on the server.
208+
// Read more: https://nextjs.org/docs/messages/failed-to-find-server-action"
209+
expect(content).toBe(`"A"`)
210+
})
211+
212+
test('should scope route handler to initial deploy when manual fetch have X-Deployment-Id request header', async ({
213+
page,
214+
skewProtection,
215+
prepareSkewProtectionScenario,
216+
}) => {
217+
await prepareSkewProtectionScenario(async () => {
218+
return await page.goto(`${skewProtection.url}/app-router`)
219+
})
220+
221+
page.getByTestId('scoped-route-handler-button').click()
222+
223+
const element = await page.waitForSelector('[data-testid="scoped-route-handler-result"]')
224+
const content = await element.textContent()
225+
226+
// if skew protection does not work, this will be "B" (currently published deploy)
227+
expect(content).toBe(`"A"`)
228+
})
229+
230+
test('should NOT scope route handler to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({
231+
page,
232+
skewProtection,
233+
prepareSkewProtectionScenario,
234+
}) => {
235+
// this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in
236+
// it's added here mostly to document this interaction
237+
await prepareSkewProtectionScenario(async () => {
238+
return await page.goto(`${skewProtection.url}/app-router`)
239+
})
240+
241+
page.getByTestId('unscoped-route-handler-button').click()
242+
243+
const element = await page.waitForSelector('[data-testid="unscoped-route-handler-result"]')
244+
const content = await element.textContent()
245+
246+
// when fetch in not scoped, it will use currently published deploy, so "B" is expected
247+
expect(content).toBe(`"B"`)
248+
})
249+
})
250+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"use server";
2+
3+
export async function testAction() {
4+
return process.env.SKEW_VARIANT;
5+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use client'
2+
3+
export function ClientComponent() {
4+
return (
5+
<p>
6+
Client Component - variant:{' '}
7+
<span data-testid="linked-page-client-component-current-variant">
8+
{process.env.SKEW_VARIANT}
9+
</span>
10+
</p>
11+
)
12+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ClientComponent } from './client-component'
2+
3+
export default function Page() {
4+
return (
5+
<>
6+
<h1>Skew Protection Testing - App Router - next/link navigation test</h1>
7+
<p>
8+
Current variant:{' '}
9+
<span data-testid="linked-page-server-component-current-variant">
10+
{process.env.SKEW_VARIANT}
11+
</span>
12+
</p>
13+
<ClientComponent />
14+
</>
15+
)
16+
}

0 commit comments

Comments
 (0)