Skip to content

internal: (studio) quality of life improvements for the studio lifecycle #31663

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import path from 'path'
import os from 'os'
import { ensureDir, copy, readFile } from 'fs-extra'
import { ensureDir, copy, readFile, remove } from 'fs-extra'
import { StudioManager } from '../../studio'
import tar from 'tar'
import { verifySignatureFromFile } from '../../encryption'
import crypto from 'crypto'
import fs from 'fs'
import fetch from 'cross-fetch'
import { agent } from '@packages/network'
Expand Down Expand Up @@ -47,6 +46,10 @@ const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Opt
encrypt: 'signed',
})

if (!response.ok) {
throw new Error(`Failed to download studio bundle: ${response.statusText}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the user experience with throwing this error?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We catch it up stream and report it. I'll take a look at @astone123's PR to make sure it plays nicely there, but prior to this change it would actually have failed in a different (more confusing) way.

}

responseSignature = response.headers.get('x-cypress-signature')

await new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -77,26 +80,12 @@ const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Opt
}
}

const getTarHash = (): Promise<string> => {
let hash = ''

return new Promise<string>((resolve, reject) => {
fs.createReadStream(bundlePath)
.pipe(crypto.createHash('sha256'))
.setEncoding('base64url')
.on('data', (data) => {
hash += String(data)
})
.on('error', reject)
.on('close', () => {
resolve(hash)
})
})
}

export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: Options): Promise<{ studioHash: string | undefined }> => {
// The studio hash is the last part of the studio URL, after the last slash and before the extension
const studioHash = studioUrl.split('/').pop()?.split('.')[0]

// First remove studioPath to ensure we have a clean slate
await fs.promises.rm(studioPath, { recursive: true, force: true })
await remove(studioPath)
await ensureDir(studioPath)

// Note: CYPRESS_LOCAL_STUDIO_PATH is stripped from the binary, effectively removing this code path
Expand All @@ -112,8 +101,6 @@ export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: O

await downloadStudioBundleToTempDirectory({ studioUrl, projectId })

const studioHash = await getTarHash()

await tar.extract({
file: bundlePath,
cwd: studioPath,
Expand Down Expand Up @@ -177,6 +164,6 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou
studioMethod: 'getAndInitializeStudioManager',
})
} finally {
await fs.promises.rm(bundlePath, { force: true })
await remove(bundlePath)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const postStudioSession = async ({ projectId }: GetStudioSessionOptions)
})

if (!response.ok) {
throw new Error('Failed to create studio session')
throw new Error(`Failed to create studio session: ${response.statusText}`)
}

const data = await response.json()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ describe('getAndInitializeStudioManager', () => {
ensureStub = sinon.stub()
copyStub = sinon.stub()
readFileStub = sinon.stub()
crossFetchStub = sinon.stub()
crossFetchStub = sinon.stub().resolves({
ok: true,
statusText: 'OK',
})

createReadStreamStub = sinon.stub()
createWriteStreamStub = sinon.stub()
verifySignatureFromFileStub = sinon.stub()
Expand All @@ -38,9 +42,6 @@ describe('getAndInitializeStudioManager', () => {

getAndInitializeStudioManager = (proxyquire('../lib/cloud/api/studio/get_and_initialize_studio_manager', {
fs: {
promises: {
rm: rmStub.resolves(),
},
createReadStream: createReadStreamStub,
createWriteStream: createWriteStreamStub,
},
Expand All @@ -49,6 +50,7 @@ describe('getAndInitializeStudioManager', () => {
platform: () => 'linux',
},
'fs-extra': {
remove: rmStub.resolves(),
ensureDir: ensureStub.resolves(),
copy: copyStub.resolves(),
readFile: readFileStub.resolves('console.log("studio script")'),
Expand Down Expand Up @@ -110,6 +112,8 @@ describe('getAndInitializeStudioManager', () => {
})

crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
Expand Down Expand Up @@ -251,6 +255,8 @@ describe('getAndInitializeStudioManager', () => {
})

crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
Expand Down Expand Up @@ -298,7 +304,7 @@ describe('getAndInitializeStudioManager', () => {
expect(studioManagerSetupStub).to.be.calledWithMatch({
script: 'console.log("studio script")',
studioPath: '/tmp/cypress/studio',
studioHash: 'V8T1PKuSTK1h9gr-1Z2Wtx__bxTpCXWRZ57sKmPVTSs',
studioHash: 'abc',
})
})

Expand All @@ -318,6 +324,8 @@ describe('getAndInitializeStudioManager', () => {

crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub()))
crossFetchStub.onSecondCall().resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
Expand Down Expand Up @@ -365,7 +373,7 @@ describe('getAndInitializeStudioManager', () => {
expect(studioManagerSetupStub).to.be.calledWithMatch({
script: 'console.log("studio script")',
studioPath: '/tmp/cypress/studio',
studioHash: 'V8T1PKuSTK1h9gr-1Z2Wtx__bxTpCXWRZ57sKmPVTSs',
studioHash: 'abc',
})
})

Expand Down Expand Up @@ -424,6 +432,43 @@ describe('getAndInitializeStudioManager', () => {
})
})

it('throws an error and returns a studio manager in error state if the response status is not ok', async () => {
const mockGetCloudUrl = sinon.stub()
const mockAdditionalHeaders = sinon.stub()
const cloud = {
getCloudUrl: mockGetCloudUrl,
additionalHeaders: mockAdditionalHeaders,
} as unknown as CloudDataSource

mockGetCloudUrl.returns('http://localhost:1234')
mockAdditionalHeaders.resolves({
a: 'b',
c: 'd',
})

crossFetchStub.resolves({
ok: false,
statusText: 'Some failure',
})

const projectId = '12345'

await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud })

expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
expect(createInErrorManagerStub).to.be.calledWithMatch({
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Failed to download studio bundle: Some failure')),
cloudApi: {
cloudUrl: 'http://localhost:1234',
cloudHeaders: { a: 'b', c: 'd' },
},
studioHash: undefined,
projectSlug: '12345',
studioMethod: 'getAndInitializeStudioManager',
})
})

it('throws an error and returns a studio manager in error state if the signature verification fails', async () => {
const mockGetCloudUrl = sinon.stub()
const mockAdditionalHeaders = sinon.stub()
Expand All @@ -439,6 +484,8 @@ describe('getAndInitializeStudioManager', () => {
})

crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
Expand Down Expand Up @@ -501,6 +548,8 @@ describe('getAndInitializeStudioManager', () => {
})

crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: () => null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('postStudioSession', () => {
it('should throw immediately if the response is not ok', async () => {
crossFetchStub.resolves({
ok: false,
statusText: 'Some failure',
json: () => {
return Promise.resolve({
error: 'Failed to create studio session',
Expand All @@ -63,7 +64,7 @@ describe('postStudioSession', () => {

await expect(postStudioSession({
projectId: '12345',
})).to.be.rejectedWith('Failed to create studio session')
})).to.be.rejectedWith('Failed to create studio session: Some failure')

expect(crossFetchStub).to.have.been.calledOnce
})
Expand Down
Loading