Skip to content

Commit 7b46b9b

Browse files
fix: concurrent cy.prompt() and Studio bundle download issues (#33034)
* chore: refactor cy.prompt/studio bundle code downloads * additional rework * further refactoring * add changelog * Update cli/CHANGELOG.md * Update cli/CHANGELOG.md * Update cli/CHANGELOG.md * cursor comment --------- Co-authored-by: Jennifer Shehane <[email protected]>
1 parent b04adc5 commit 7b46b9b

File tree

12 files changed

+809
-52
lines changed

12 files changed

+809
-52
lines changed

cli/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ _Released 12/2/2025 (PENDING)_
77

88
- Improved performance when viewing command snapshots in the Command Log. Element highlighting is now significantly faster, especially when highlighting multiple elements or complex pages. This is achieved by reducing redundant style calculations and batching DOM operations to minimize browser reflows. Addressed in [#32951](https://github.com/cypress-io/cypress/pull/32951).
99

10+
**Bugfixes:**
11+
12+
- Updated the error message shown when the `cy.prompt()` bundle is deleted while in use. Ensured that the Cloud bundles are written atomically to avoid concurrent downloads causing issues. Addressed in [#33034](https://github.com/cypress-io/cypress/pull/33034).
13+
1014
## 15.7.0
1115

1216
_Released 11/19/2025_

packages/driver/src/cy/commands/prompt/index.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,29 @@ const initializeModule = async (Cypress: Cypress.Cypress): Promise<CyPromptDrive
6363
})
6464
}
6565

66+
let module: CyPromptDriver | null = null
67+
6668
// Once the cy prompt bundle is downloaded and ready,
6769
// we can initialize it via the module federation runtime
68-
init({
69-
remotes: [{
70-
alias: 'cy-prompt',
71-
type: 'module',
72-
name: 'cy-prompt',
73-
entryGlobalName: 'cy-prompt',
74-
entry: '/__cypress-cy-prompt/driver/cy-prompt.js',
75-
shareScope: 'default',
76-
}],
77-
name: 'driver',
78-
})
79-
80-
// This cy-prompt.js file and any subsequent files are
81-
// served from the cy prompt bundle.
82-
const module = await loadRemote<CyPromptDriver>('cy-prompt')
70+
try {
71+
init({
72+
remotes: [{
73+
alias: 'cy-prompt',
74+
type: 'module',
75+
name: 'cy-prompt',
76+
entryGlobalName: 'cy-prompt',
77+
entry: '/__cypress-cy-prompt/driver/cy-prompt.js',
78+
shareScope: 'default',
79+
}],
80+
name: 'driver',
81+
})
82+
83+
// This cy-prompt.js file and any subsequent files are
84+
// served from the cy prompt bundle.
85+
module = await loadRemote<CyPromptDriver>('cy-prompt')
86+
} catch (error) {
87+
$errUtils.throwErrByPath('prompt.promptBundleNeedsRefresh')
88+
}
8389

8490
if (!module?.default) {
8591
$errUtils.throwErrByPath('prompt.promptDownloadError', {

packages/driver/src/cypress/error_messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,11 @@ export default {
13541354
docsUrl: 'https://on.cypress.io/prompt-download-error',
13551355
}
13561356
},
1357+
promptBundleNeedsRefresh: stripIndent`\
1358+
Your \`cy.prompt\` Cloud code needs to be refreshed.
1359+
1360+
Please restart the app to get the latest version.
1361+
`,
13571362
promptProxyError: {
13581363
message: stripIndent`\
13591364
\`cy.prompt\` requires an internet connection. To continue, you may need to configure Cypress with your proxy settings.

packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { remove, ensureDir, readFile, pathExists } from 'fs-extra'
2-
3-
import tar from 'tar'
1+
import { ensureDir, readFile, pathExists, remove } from 'fs-extra'
42
import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle'
53
import path from 'path'
64
import { verifySignature } from '../encryption'
5+
import { extractAtomic } from '../extract_atomic'
76

87
interface EnsureCyPromptBundleOptions {
98
cyPromptPath: string
@@ -21,19 +20,17 @@ interface EnsureCyPromptBundleOptions {
2120
export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions): Promise<Record<string, string>> => {
2221
const bundlePath = path.join(cyPromptPath, 'bundle.tar')
2322

24-
// First remove cyPromptPath to ensure we have a clean slate
25-
await remove(cyPromptPath)
2623
await ensureDir(cyPromptPath)
2724

25+
const uniqueBundlePath = `${bundlePath}-${Math.random().toString(36).substring(2, 15)}`
2826
const responseManifestSignature: string = await getCyPromptBundle({
2927
cyPromptUrl,
3028
projectId,
31-
bundlePath,
29+
bundlePath: uniqueBundlePath,
3230
})
3331

34-
await tar.extract({
35-
file: bundlePath,
36-
cwd: cyPromptPath,
32+
await extractAtomic(uniqueBundlePath, cyPromptPath).finally(async () => {
33+
await remove(uniqueBundlePath).catch(() => { /* ignore */ })
3734
})
3835

3936
const manifestPath = path.join(cyPromptPath, 'manifest.json')
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createReadStream } from 'fs'
2+
import tar from 'tar'
3+
import { ensureDir } from 'fs-extra'
4+
import path from 'path'
5+
import writeFileAtomic from 'write-file-atomic'
6+
7+
export const extractAtomic = async (archivePath: string, destinationPath: string) => {
8+
const entryPromises: Promise<void>[] = []
9+
10+
const parser = new tar.Parse()
11+
12+
parser.on('entry', (entry) => {
13+
if (entry.type !== 'File') {
14+
entry.resume() // skip non-files
15+
16+
return
17+
}
18+
19+
const targetPath = path.join(destinationPath, entry.path)
20+
21+
const p = (async () => {
22+
await ensureDir(path.dirname(targetPath))
23+
24+
const chunks: Buffer[] = []
25+
26+
for await (const chunk of entry) {
27+
chunks.push(chunk)
28+
}
29+
30+
const content = Buffer.concat(chunks)
31+
32+
await writeFileAtomic(targetPath, content, {
33+
mode: entry.mode || 0o644,
34+
})
35+
})()
36+
37+
entryPromises.push(p)
38+
})
39+
40+
// Pipe archive into parser
41+
const stream = createReadStream(archivePath)
42+
43+
stream.pipe(parser)
44+
45+
// Wait for parser to finish and all entry writes to complete
46+
await new Promise<void>((resolve, reject) => {
47+
parser.on('end', resolve)
48+
// Parser extends NodeJS.ReadWriteStream (EventEmitter), so it supports 'error' events
49+
// even though the types don't explicitly declare it
50+
51+
;(parser as NodeJS.ReadWriteStream).on('error', reject)
52+
stream.on('error', reject)
53+
})
54+
55+
await Promise.all(entryPromises)
56+
}

packages/server/lib/cloud/studio/ensure_studio_bundle.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { remove, ensureDir, readFile, pathExists } from 'fs-extra'
2-
3-
import tar from 'tar'
42
import { getStudioBundle } from '../api/studio/get_studio_bundle'
53
import path from 'path'
64
import { verifySignature } from '../encryption'
5+
import { extractAtomic } from '../extract_atomic'
76

87
interface EnsureStudioBundleOptions {
98
studioUrl: string
@@ -25,18 +24,16 @@ export const ensureStudioBundle = async ({
2524
}: EnsureStudioBundleOptions): Promise<Record<string, string>> => {
2625
const bundlePath = path.join(studioPath, 'bundle.tar')
2726

28-
// First remove studioPath to ensure we have a clean slate
29-
await remove(studioPath)
3027
await ensureDir(studioPath)
3128

32-
const responseManifestSignature = await getStudioBundle({
29+
const uniqueBundlePath = `${bundlePath}-${Math.random().toString(36).substring(2, 15)}`
30+
const responseManifestSignature: string = await getStudioBundle({
3331
studioUrl,
34-
bundlePath,
32+
bundlePath: uniqueBundlePath,
3533
})
3634

37-
await tar.extract({
38-
file: bundlePath,
39-
cwd: studioPath,
35+
await extractAtomic(uniqueBundlePath, studioPath).finally(async () => {
36+
await remove(uniqueBundlePath).catch(() => { /* ignore */ })
4037
})
4138

4239
const manifestPath = path.join(studioPath, 'manifest.json')

packages/server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@
136136
"wait-port": "1.1.0",
137137
"webdriver": "9.14.0",
138138
"webpack-virtual-modules": "0.5.0",
139-
"widest-line": "3.1.0"
139+
"widest-line": "3.1.0",
140+
"write-file-atomic": "7.0.0"
140141
},
141142
"devDependencies": {
142143
"@babel/core": "7.28.0",

0 commit comments

Comments
 (0)