Skip to content

Commit 468d9a4

Browse files
Merge branch 'develop' into feat/cy-prompt
2 parents 8588a20 + 8f5c241 commit 468d9a4

File tree

14 files changed

+378
-66
lines changed

14 files changed

+378
-66
lines changed

cli/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
2+
## 14.5.2
3+
4+
_Released 7/15/2025 (PENDING)_
5+
6+
**Bugfixes:**
7+
8+
- Fixed a regression introduced in [`14.5.0`](https://docs.cypress.io/guides/references/changelog#14-5-0) where the Stop button would not immediately stop the spec timer. Addresses [#31920](https://github.com/cypress-io/cypress/issues/31920).
9+
210
## 14.5.1
311

412
_Released 7/01/2025_

packages/app/cypress/e2e/runner/runner.ui.cy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,10 +289,15 @@ describe('src/cypress/runner', () => {
289289
it('user can stop test execution', () => {
290290
loadSpec({
291291
filePath: 'runner/stop-execution.runner.cy.js',
292-
passCount: 0,
293-
failCount: 1,
294292
})
295293

294+
// Click the stop button to stop execution
295+
cy.get('.stop').click()
296+
297+
// Verify the UI updates immediately - stop button disappears, restart appears
298+
cy.get('.stop', { timeout: 100 }).should('not.exist')
299+
cy.get('.restart', { timeout: 100 }).should('be.visible')
300+
296301
cy.get('.runnable-err-message').should('not.contain', 'ran afterEach even though specs were stopped')
297302
cy.get('.runnable-err-message').should('contain', 'Cypress test was stopped while running this command.')
298303
})

packages/driver/src/cypress/runner.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ const isRootSuite = (suite) => {
393393
return suite && suite.root
394394
}
395395

396-
const overrideRunnerHook = (Cypress, _runner, getTestById, getTest, setTest, getTests, cy) => {
396+
const overrideRunnerHook = (Cypress, _runner, getTestById, getTest, setTest, getTests, cy, abort) => {
397397
// bail if our _runner doesn't have a hook.
398398
// useful in tests
399399
if (!_runner.hook) {
@@ -557,22 +557,11 @@ const overrideRunnerHook = (Cypress, _runner, getTestById, getTest, setTest, get
557557
testAfterRun(test, Cypress)
558558
await testAfterRunAsync(test, Cypress)
559559

560-
// if the user has stopped the run, we need to abort,
560+
// if the user has stopped the run and we are in run mode, we need to abort,
561561
// this needs to happen after the test:after:run events have fired
562562
// to ensure protocol can properly handle the abort
563-
if (_runner.stopped) {
564-
// abort the run
565-
_runner.abort()
566-
567-
// emit the final 'end' event
568-
// since our reporter depends on this event
569-
// and mocha may never fire this because our
570-
// runnable may never finish
571-
_runner.emit('end')
572-
573-
// remove all the listeners
574-
// so no more events fire
575-
_runner.removeAllListeners()
563+
if (_runner.stopped && isRunMode) {
564+
abort()
576565
}
577566
})]
578567

@@ -1407,7 +1396,22 @@ export default {
14071396

14081397
const getOnlySuiteId = () => _onlySuiteId
14091398

1410-
overrideRunnerHook(Cypress, _runner, getTestById, getTest, setTest, getTests, cy)
1399+
const abort = () => {
1400+
// abort the run
1401+
_runner.abort()
1402+
1403+
// emit the final 'end' event
1404+
// since our reporter depends on this event
1405+
// and mocha may never fire this because our
1406+
// runnable may never finish
1407+
_runner.emit('end')
1408+
1409+
// remove all the listeners
1410+
// so no more events fire
1411+
_runner.removeAllListeners()
1412+
}
1413+
1414+
overrideRunnerHook(Cypress, _runner, getTestById, getTest, setTest, getTests, cy, abort)
14111415

14121416
// this forces mocha to enqueue a duplicate test in the case of test retries
14131417
const replacePreviousAttemptWith = (test) => {
@@ -1934,6 +1938,12 @@ export default {
19341938
}
19351939

19361940
_runner.stopped = true
1941+
1942+
// if we are in open mode, abort the run immediately
1943+
// since we want the user feedback to be immediate
1944+
if (Cypress.config('isInteractive')) {
1945+
abort()
1946+
}
19371947
},
19381948

19391949
getDisplayPropsForLog: LogUtils.getDisplayProps,

packages/server/lib/cloud/api/studio/get_studio_bundle.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import { verifySignatureFromFile } from '../../encryption'
1010
const pkg = require('@packages/root')
1111
const _delay = linearDelay(500)
1212

13-
export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { studioUrl: string, projectId?: string, bundlePath: string }) => {
13+
export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise<string> => {
1414
let responseSignature: string | null = null
15+
let responseManifestSignature: string | null = null
1516

1617
await (asyncRetry(async () => {
1718
const response = await fetch(studioUrl, {
@@ -32,6 +33,7 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st
3233
}
3334

3435
responseSignature = response.headers.get('x-cypress-signature')
36+
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
3537

3638
await new Promise<void>((resolve, reject) => {
3739
const writeStream = createWriteStream(bundlePath)
@@ -54,9 +56,15 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st
5456
throw new Error('Unable to get studio signature')
5557
}
5658

59+
if (!responseManifestSignature) {
60+
throw new Error('Unable to get studio manifest signature')
61+
}
62+
5763
const verified = await verifySignatureFromFile(bundlePath, responseSignature)
5864

5965
if (!verified) {
6066
throw new Error('Unable to verify studio signature')
6167
}
68+
69+
return responseManifestSignature
6270
}

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ import { initializeTelemetryReporter, reportTelemetry } from './telemetry/Teleme
2222
import { telemetryManager } from './telemetry/TelemetryManager'
2323
import { BUNDLE_LIFECYCLE_MARK_NAMES, BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES } from './telemetry/constants/bundle-lifecycle'
2424
import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/initialization'
25+
import crypto from 'crypto'
2526

2627
const debug = Debug('cypress:server:studio-lifecycle-manager')
2728
const routes = require('../routes')
2829

2930
export class StudioLifecycleManager {
30-
private static hashLoadingMap: Map<string, Promise<void>> = new Map()
31+
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
3132
private static watcher: chokidar.FSWatcher | null = null
3233
private studioManagerPromise?: Promise<StudioManager | null>
3334
private studioManager?: StudioManager
@@ -157,6 +158,7 @@ export class StudioLifecycleManager {
157158
}): Promise<StudioManager> {
158159
let studioPath: string
159160
let studioHash: string
161+
let manifest: Record<string, string>
160162

161163
initializeTelemetryReporter({
162164
projectSlug: projectId,
@@ -190,17 +192,32 @@ export class StudioLifecycleManager {
190192
StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
191193
}
192194

193-
await hashLoadingPromise
195+
manifest = await hashLoadingPromise
194196
} else {
195197
studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH
196198
studioHash = 'local'
199+
manifest = {}
197200
}
198201

199202
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_END)
200203

201204
const serverFilePath = path.join(studioPath, 'server', 'index.js')
202205

203206
const script = await readFile(serverFilePath, 'utf8')
207+
208+
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
209+
const expectedHash = manifest['server/index.js']
210+
const actualHash = crypto.createHash('sha256').update(script).digest('hex')
211+
212+
if (!expectedHash) {
213+
throw new Error('Expected hash for studio server script not found in manifest')
214+
}
215+
216+
if (actualHash !== expectedHash) {
217+
throw new Error('Invalid hash for studio server script')
218+
}
219+
}
220+
204221
const studioManager = new StudioManager()
205222

206223
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_START)
@@ -220,6 +237,7 @@ export class StudioLifecycleManager {
220237
asyncRetry,
221238
},
222239
shouldEnableStudio: this.cloudStudioRequested,
240+
manifest,
223241
})
224242

225243
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END)

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { remove, ensureDir } from 'fs-extra'
1+
import { remove, ensureDir, readFile, pathExists } from 'fs-extra'
22

33
import tar from 'tar'
44
import { getStudioBundle } from '../api/studio/get_studio_bundle'
55
import path from 'path'
6+
import { verifySignature } from '../encryption'
67

78
interface EnsureStudioBundleOptions {
89
studioUrl: string
@@ -26,7 +27,7 @@ export const ensureStudioBundle = async ({
2627
projectId,
2728
studioPath,
2829
downloadTimeoutMs = DOWNLOAD_TIMEOUT,
29-
}: EnsureStudioBundleOptions) => {
30+
}: EnsureStudioBundleOptions): Promise<Record<string, string>> => {
3031
const bundlePath = path.join(studioPath, 'bundle.tar')
3132

3233
// First remove studioPath to ensure we have a clean slate
@@ -35,10 +36,9 @@ export const ensureStudioBundle = async ({
3536

3637
let timeoutId: NodeJS.Timeout
3738

38-
await Promise.race([
39+
const responseManifestSignature: string = await Promise.race([
3940
getStudioBundle({
4041
studioUrl,
41-
projectId,
4242
bundlePath,
4343
}),
4444
new Promise((_, reject) => {
@@ -48,10 +48,26 @@ export const ensureStudioBundle = async ({
4848
}),
4949
]).finally(() => {
5050
clearTimeout(timeoutId)
51-
})
51+
}) as string
5252

5353
await tar.extract({
5454
file: bundlePath,
5555
cwd: studioPath,
5656
})
57+
58+
const manifestPath = path.join(studioPath, 'manifest.json')
59+
60+
if (!(await pathExists(manifestPath))) {
61+
throw new Error('Unable to find studio manifest')
62+
}
63+
64+
const manifestContents = await readFile(manifestPath, 'utf8')
65+
66+
const verified = await verifySignature(manifestContents, responseManifestSignature)
67+
68+
if (!verified) {
69+
throw new Error('Unable to verify studio signature')
70+
}
71+
72+
return JSON.parse(manifestContents)
5773
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Debug from 'debug'
55
import { requireScript } from '../require_script'
66
import path from 'path'
77
import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error'
8+
import crypto, { BinaryLike } from 'crypto'
89

910
interface StudioServer { default: StudioServerDefaultShape }
1011

@@ -15,6 +16,7 @@ interface SetupOptions {
1516
projectSlug?: string
1617
cloudApi: StudioCloudApi
1718
shouldEnableStudio: boolean
19+
manifest: Record<string, string>
1820
}
1921

2022
const debug = Debug('cypress:server:studio')
@@ -41,7 +43,7 @@ export class StudioManager implements StudioManagerShape {
4143
return manager
4244
}
4345

44-
async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio }: SetupOptions): Promise<void> {
46+
async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, manifest }: SetupOptions): Promise<void> {
4547
const { createStudioServer } = requireScript<StudioServer>(script).default
4648

4749
this._studioServer = await createStudioServer({
@@ -50,6 +52,18 @@ export class StudioManager implements StudioManagerShape {
5052
projectSlug,
5153
cloudApi,
5254
betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')),
55+
manifest,
56+
verifyHash: (contents: BinaryLike, expectedHash: string) => {
57+
// If we are running locally, we don't need to verify the signature. This
58+
// environment variable will get stripped in the binary.
59+
if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
60+
return true
61+
}
62+
63+
const actualHash = crypto.createHash('sha256').update(contents).digest('hex')
64+
65+
return actualHash === expectedHash
66+
},
5367
})
5468

5569
this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED'

0 commit comments

Comments
 (0)