mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-26 19:09:32 -06:00
fix: Guard against slow requests in GraphQL Resolution (part 2) (#21020)
* add nexusDeferIfNotLoadedPlugin, remove GraphQLDataSource & ssr of graphql data * add cachedUser for better rendering when cloudViewer is invalidated, fix types, tests * guard for login to be visible before making next assertion * revert onCacheUpdate changes * fix for percy snapshots * address @flotwig's simpler feedback * Address types for versionData * allow for nullish email in UserAvatar * Ignore remote schema parent type in onCreateFieldResolver
This commit is contained in:
@@ -44,24 +44,6 @@ config:
|
||||
DateTime: string
|
||||
JSON: any
|
||||
generates:
|
||||
'./packages/data-context/src/gen/all-operations.gen.ts':
|
||||
config:
|
||||
<<: *documentFilters
|
||||
flattenGeneratedTypes: true
|
||||
schema: 'packages/graphql/schemas/schema.graphql'
|
||||
documents:
|
||||
- './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/app/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
- './packages/launchpad/src/**/*.{vue,ts,tsx,js,jsx}'
|
||||
plugins:
|
||||
- add:
|
||||
content: '/* eslint-disable */'
|
||||
- 'typescript':
|
||||
noExport: true
|
||||
- 'typescript-operations':
|
||||
noExport: true
|
||||
- 'typed-document-node'
|
||||
|
||||
###
|
||||
# Generates types for us to infer the correct "source types" when we mock out on the frontend
|
||||
# This ensures we have proper type checking when we're using cy.mountFragment in component tests
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
spec: 'test/**/*.spec.ts',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
spec: 'test/**/*.spec.ts',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('Sidebar Navigation', () => {
|
||||
|
||||
it('closes the left nav bar when clicking the expand button and persist the state if browser is refreshed', () => {
|
||||
cy.findByLabelText('Sidebar').closest('[aria-expanded]').should('have.attr', 'aria-expanded', 'true')
|
||||
cy.contains('todos')
|
||||
cy.findAllByText('todos').eq(1).as('title')
|
||||
cy.get('@title').should('be.visible')
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('App: Spec List (E2E)', () => {
|
||||
})
|
||||
|
||||
cy.visitApp()
|
||||
cy.contains('E2E Specs')
|
||||
})
|
||||
|
||||
it('shows the "Specs" navigation as highlighted in the lefthand nav bar', () => {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { SinonStub } from 'sinon'
|
||||
import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json'
|
||||
import type Sinon from 'sinon'
|
||||
|
||||
const pkg = require('@packages/root')
|
||||
|
||||
const loginText = defaultMessages.topNav.login
|
||||
|
||||
beforeEach(() => {
|
||||
cy.clock(Date.UTC(2021, 9, 30), ['Date'])
|
||||
})
|
||||
|
||||
describe('App Top Nav Workflows', () => {
|
||||
beforeEach(() => {
|
||||
cy.scaffoldProject('launchpad')
|
||||
|
||||
cy.clock(Date.UTC(2021, 9, 30), ['Date'])
|
||||
})
|
||||
|
||||
describe('Page Name', () => {
|
||||
@@ -219,16 +222,15 @@ describe('App Top Nav Workflows', () => {
|
||||
context('version data unreachable', () => {
|
||||
it('treats unreachable data as current version', () => {
|
||||
cy.withCtx((ctx, o) => {
|
||||
(ctx.util.fetch as Sinon.SinonStub).restore()
|
||||
const oldFetch = ctx.util.fetch
|
||||
|
||||
o.sinon.stub(ctx.util, 'fetch').get(() => {
|
||||
return async (url: RequestInfo, init?: RequestInit) => {
|
||||
if (['https://download.cypress.io/desktop.json', 'https://registry.npmjs.org/cypress'].includes(String(url))) {
|
||||
throw new Error(String(url))
|
||||
}
|
||||
|
||||
return oldFetch(url, init)
|
||||
o.sinon.stub(ctx.util, 'fetch').callsFake(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (['https://download.cypress.io/desktop.json', 'https://registry.npmjs.org/cypress'].includes(String(url))) {
|
||||
throw new Error(String(url))
|
||||
}
|
||||
|
||||
return oldFetch(url, init)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -627,6 +629,8 @@ describe('Growth Prompts Can Open Automatically', () => {
|
||||
)
|
||||
|
||||
cy.visitApp()
|
||||
cy.contains('E2E Specs')
|
||||
cy.wait(1000)
|
||||
cy.contains('Configure CI').should('be.visible')
|
||||
})
|
||||
|
||||
@@ -642,6 +646,8 @@ describe('Growth Prompts Can Open Automatically', () => {
|
||||
)
|
||||
|
||||
cy.visitApp()
|
||||
cy.contains('E2E Specs')
|
||||
cy.wait(1000)
|
||||
cy.contains('Configure CI').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,9 +38,11 @@ app.use(Toast, {
|
||||
closeOnClick: false,
|
||||
})
|
||||
|
||||
app.use(urql, makeUrqlClient({ target: 'app', namespace: config.namespace, socketIoRoute: config.socketIoRoute }))
|
||||
app.use(createRouter())
|
||||
app.use(createI18n())
|
||||
app.use(createPinia())
|
||||
makeUrqlClient({ target: 'app', namespace: config.namespace, socketIoRoute: config.socketIoRoute }).then((client) => {
|
||||
app.use(urql, client)
|
||||
app.use(createRouter())
|
||||
app.use(createI18n())
|
||||
app.use(createPinia())
|
||||
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
@@ -131,7 +131,7 @@ query SideBarNavigation {
|
||||
}
|
||||
`
|
||||
|
||||
const query = useQuery({ query: SideBarNavigationDocument, requestPolicy: 'network-only' })
|
||||
const query = useQuery({ query: SideBarNavigationDocument })
|
||||
|
||||
const setPreferences = useMutation(SideBarNavigation_SetPreferencesDocument)
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
@showCreateSpecModal="showCreateSpecModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
Loading...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -23,19 +23,18 @@ import {
|
||||
StorybookDataSource,
|
||||
CloudDataSource,
|
||||
EnvDataSource,
|
||||
GraphQLDataSource,
|
||||
HtmlDataSource,
|
||||
UtilDataSource,
|
||||
BrowserApiShape,
|
||||
MigrationDataSource,
|
||||
} from './sources/'
|
||||
import { cached } from './util/cached'
|
||||
import type { GraphQLSchema } from 'graphql'
|
||||
import type { Server } from 'http'
|
||||
import type { GraphQLSchema, OperationTypeNode, DocumentNode } from 'graphql'
|
||||
import type { IncomingHttpHeaders, Server } from 'http'
|
||||
import type { AddressInfo } from 'net'
|
||||
import type { App as ElectronApp } from 'electron'
|
||||
import { VersionsDataSource } from './sources/VersionsDataSource'
|
||||
import type { Socket, SocketIOServer } from '@packages/socket'
|
||||
import type { SocketIOServer } from '@packages/socket'
|
||||
import { globalPubSub } from '.'
|
||||
import { InjectedConfigApi, ProjectLifecycleManager } from './data/ProjectLifecycleManager'
|
||||
import type { CypressError } from '@packages/errors'
|
||||
@@ -69,7 +68,17 @@ export interface DataContextConfig {
|
||||
browserApi: BrowserApiShape
|
||||
}
|
||||
|
||||
export interface GraphQLRequestInfo {
|
||||
app: 'app' | 'launchpad'
|
||||
operationName: string | null
|
||||
document: DocumentNode
|
||||
operation: OperationTypeNode
|
||||
variables: Record<string, any> | null
|
||||
headers: IncomingHttpHeaders
|
||||
}
|
||||
|
||||
export class DataContext {
|
||||
readonly graphqlRequestInfo?: GraphQLRequestInfo
|
||||
private _config: Omit<DataContextConfig, 'modeOptions'>
|
||||
private _modeOptions: Readonly<Partial<AllModeOptions>>
|
||||
private _coreData: CoreDataShape
|
||||
@@ -194,10 +203,6 @@ export class DataContext {
|
||||
return new DataEmitterActions(this)
|
||||
}
|
||||
|
||||
graphqlClient () {
|
||||
return new GraphQLDataSource(this, this._config.schema)
|
||||
}
|
||||
|
||||
@cached
|
||||
get html () {
|
||||
return new HtmlDataSource(this)
|
||||
@@ -232,11 +237,7 @@ export class DataContext {
|
||||
|
||||
setAppSocketServer (socketServer: SocketIOServer | undefined) {
|
||||
this.update((d) => {
|
||||
if (d.servers.appSocketServer !== socketServer) {
|
||||
d.servers.appSocketServer?.off('connection', this.initialPush)
|
||||
socketServer?.on('connection', this.initialPush)
|
||||
}
|
||||
|
||||
d.servers.appSocketServer?.disconnectSockets(true)
|
||||
d.servers.appSocketServer = socketServer
|
||||
})
|
||||
}
|
||||
@@ -250,23 +251,11 @@ export class DataContext {
|
||||
|
||||
setGqlSocketServer (socketServer: SocketIOServer | undefined) {
|
||||
this.update((d) => {
|
||||
if (d.servers.gqlSocketServer !== socketServer) {
|
||||
d.servers.gqlSocketServer?.off('connection', this.initialPush)
|
||||
socketServer?.on('connection', this.initialPush)
|
||||
}
|
||||
|
||||
d.servers.gqlSocketServer?.disconnectSockets(true)
|
||||
d.servers.gqlSocketServer = socketServer
|
||||
})
|
||||
}
|
||||
|
||||
initialPush = (socket: Socket) => {
|
||||
// TODO: This is a hack that will go away when we refine the whole socket communication
|
||||
// layer w/ GraphQL subscriptions, we shouldn't be pushing so much
|
||||
setTimeout(() => {
|
||||
socket.emit('data-context-push')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* This will be replaced with Immer, for immutable state updates.
|
||||
*/
|
||||
@@ -369,16 +358,6 @@ export class DataContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If we really want to get around the guards added in proxyContext
|
||||
* which disallow referencing ctx.actions / ctx.emitter from context for a GraphQL query,
|
||||
* we can call ctx.deref.emitter, etc. This should only be used in exceptional situations where
|
||||
* we're certain this is a good idea.
|
||||
*/
|
||||
get deref () {
|
||||
return this
|
||||
}
|
||||
|
||||
async destroy () {
|
||||
const destroy = util.promisify(this.coreData.servers.gqlServer?.destroy || (() => {}))
|
||||
|
||||
@@ -441,10 +420,14 @@ export class DataContext {
|
||||
this.actions.dev.watchForRelaunch()
|
||||
}
|
||||
|
||||
// We want to fetch the user immediately, but we don't need to block the UI on this
|
||||
this.actions.auth.getUser().catch((e) => {
|
||||
// This error should never happen, since it's internally handled by getUser
|
||||
// Log anyway, just incase
|
||||
this.logTraceError(e)
|
||||
})
|
||||
|
||||
const toAwait: Promise<any>[] = [
|
||||
// load the cached user & validate the token on start
|
||||
this.actions.auth.getUser(),
|
||||
// and grab the user device settings
|
||||
this.actions.localSettings.refreshLocalSettings(),
|
||||
]
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export class DataEmitterActions extends DataEmitterEvents {
|
||||
* a re-query of data on the frontend
|
||||
*/
|
||||
toApp (...args: any[]) {
|
||||
this.ctx.coreData.servers.appSocketServer?.emit('data-context-push', ...args)
|
||||
this.ctx.coreData.servers.appSocketServer?.emit('graphql-refresh')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +81,21 @@ export class DataEmitterActions extends DataEmitterEvents {
|
||||
* typically used to trigger a re-query of data on the frontend
|
||||
*/
|
||||
toLaunchpad (...args: any[]) {
|
||||
this.ctx.coreData.servers.gqlSocketServer?.emit('data-context-push', ...args)
|
||||
this.ctx.coreData.servers.gqlSocketServer?.emit('graphql-refresh')
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the client to refetch a specific query, fired when we hit a remote data
|
||||
* source, and respond with the data before the initial hit was able to resolve
|
||||
*/
|
||||
notifyClientRefetch (target: 'app' | 'launchpad', operation: string, field: string, variables: any) {
|
||||
const server = target === 'app' ? this.ctx.coreData.servers.appSocketServer : this.ctx.coreData.servers.gqlSocketServer
|
||||
|
||||
server?.emit('graphql-refresh', {
|
||||
field,
|
||||
operation,
|
||||
variables,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -146,8 +146,10 @@ export class WizardActions {
|
||||
async scaffoldTestingType () {
|
||||
const { currentTestingType, wizard: { chosenLanguage } } = this.ctx.coreData
|
||||
|
||||
assert(currentTestingType)
|
||||
assert(chosenLanguage)
|
||||
// TODO: tgriesser, clean this up as part of UNIFY-1256
|
||||
if (!currentTestingType || !chosenLanguage) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (currentTestingType) {
|
||||
case 'e2e': {
|
||||
@@ -155,14 +157,20 @@ export class WizardActions {
|
||||
this.ctx.lifecycleManager.refreshMetaState()
|
||||
this.ctx.actions.project.setForceReconfigureProjectByTestingType({ forceReconfigureProject: false, testingType: 'e2e' })
|
||||
|
||||
return chosenLanguage
|
||||
return
|
||||
}
|
||||
case 'component': {
|
||||
const { chosenBundler, chosenFramework } = this.ctx.wizard
|
||||
|
||||
if (!chosenBundler || !chosenFramework) {
|
||||
return
|
||||
}
|
||||
|
||||
this.ctx.coreData.scaffoldedFiles = await this.scaffoldComponent()
|
||||
this.ctx.lifecycleManager.refreshMetaState()
|
||||
this.ctx.actions.project.setForceReconfigureProjectByTestingType({ forceReconfigureProject: false, testingType: 'component' })
|
||||
|
||||
return chosenLanguage
|
||||
return
|
||||
}
|
||||
default:
|
||||
throw new Error('Unreachable')
|
||||
|
||||
@@ -138,6 +138,10 @@ export interface CoreDataShape {
|
||||
packageManager: typeof PACKAGE_MANAGERS[number]
|
||||
forceReconfigureProject: ForceReconfigureProjectDataShape | null
|
||||
cancelActiveLogin: (() => void) | null
|
||||
versionData: {
|
||||
latestVersion: Promise<string>
|
||||
npmMetadata: Promise<Record<string, string>>
|
||||
} | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,5 +213,6 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
|
||||
packageManager: 'npm',
|
||||
forceReconfigureProject: null,
|
||||
cancelActiveLogin: null,
|
||||
versionData: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
|
||||
export type {
|
||||
DataContextConfig,
|
||||
GraphQLRequestInfo,
|
||||
} from './DataContext'
|
||||
|
||||
export type {
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
Client,
|
||||
createRequest,
|
||||
OperationResult,
|
||||
RequestPolicy,
|
||||
} from '@urql/core'
|
||||
import _ from 'lodash'
|
||||
import { getError } from '@packages/errors'
|
||||
import type { RemoteExecutionRoot } from '@packages/graphql'
|
||||
|
||||
const debug = debugLib('cypress:data-context:CloudDataSource')
|
||||
const cloudEnv = getenv('CYPRESS_INTERNAL_CLOUD_ENV', process.env.CYPRESS_INTERNAL_ENV || 'development') as keyof typeof REMOTE_SCHEMA_URLS
|
||||
@@ -29,12 +29,11 @@ const REMOTE_SCHEMA_URLS = {
|
||||
production: 'https://dashboard.cypress.io',
|
||||
}
|
||||
|
||||
export interface CloudExecuteRemote {
|
||||
export interface CloudExecuteRemote extends RemoteExecutionRoot {
|
||||
operationType: OperationTypeNode
|
||||
query: string
|
||||
document?: DocumentNode
|
||||
variables: any
|
||||
requestPolicy?: RequestPolicy
|
||||
}
|
||||
|
||||
export class CloudDataSource {
|
||||
@@ -52,7 +51,10 @@ export class CloudDataSource {
|
||||
cacheExchange,
|
||||
fetchExchange,
|
||||
],
|
||||
fetch: this.ctx.util.fetch,
|
||||
// Set this way so we can intercept the fetch on the context for testing
|
||||
fetch: (...args) => {
|
||||
return this.ctx.util.fetch(...args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,7 +69,7 @@ export class CloudDataSource {
|
||||
return { data: null }
|
||||
}
|
||||
|
||||
const requestPolicy = config.requestPolicy ?? 'cache-and-network'
|
||||
const requestPolicy = config.requestPolicy ?? 'cache-first'
|
||||
|
||||
const isQuery = config.operationType !== 'mutation'
|
||||
|
||||
@@ -114,10 +116,8 @@ export class CloudDataSource {
|
||||
this.ctx.coreData.dashboardGraphQLError = null
|
||||
}
|
||||
|
||||
// TODO(tim): send a signal to the frontend so when it refetches it does 'cache-only' request,
|
||||
// since we know we're up-to-date
|
||||
this.ctx.deref.emitter.toApp()
|
||||
this.ctx.deref.emitter.toLaunchpad()
|
||||
this.ctx.emitter.toApp()
|
||||
this.ctx.emitter.toLaunchpad()
|
||||
}
|
||||
|
||||
if (!res.stale) {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createClient, Client, dedupExchange, ssrExchange } from '@urql/core'
|
||||
import { cacheExchange } from '@urql/exchange-graphcache'
|
||||
import { executeExchange } from '@urql/exchange-execute'
|
||||
import { GraphQLSchema, introspectionFromSchema } from 'graphql'
|
||||
import type { DataContext } from '../DataContext'
|
||||
import type * as allOperations from '../gen/all-operations.gen'
|
||||
import { urqlCacheKeys } from '../util/urqlCacheKeys'
|
||||
|
||||
// Filter out non-Query shapes
|
||||
type AllQueries<T> = {
|
||||
[K in keyof T]: T[K] extends { __resultType?: infer U }
|
||||
? U extends { __typename?: 'Query' }
|
||||
? K
|
||||
: never
|
||||
: never
|
||||
}[keyof T]
|
||||
|
||||
export class GraphQLDataSource {
|
||||
private _urqlClient: Client
|
||||
private _ssr: ReturnType<typeof ssrExchange>
|
||||
|
||||
constructor (private ctx: DataContext, private schema: GraphQLSchema) {
|
||||
this._ssr = ssrExchange({ isClient: false })
|
||||
this._urqlClient = this.makeClient()
|
||||
}
|
||||
|
||||
resetClient () {
|
||||
this._urqlClient = this.makeClient()
|
||||
}
|
||||
|
||||
private _allQueries?: typeof allOperations
|
||||
|
||||
executeQuery (document: AllQueries<typeof allOperations>, variables: Record<string, any>) {
|
||||
// Late require'd to avoid erroring if codegen hasn't run (for legacy Cypress workflow)
|
||||
const allQueries = (this._allQueries ??= require('../gen/all-operations.gen'))
|
||||
|
||||
if (!allQueries[document]) {
|
||||
throw new Error(`Trying to execute unknown operation ${document}, needs to be one of: [${Object.keys(allQueries).join(', ')}]`)
|
||||
}
|
||||
|
||||
return this._urqlClient.query(allQueries[document], variables).toPromise()
|
||||
}
|
||||
|
||||
getSSRData () {
|
||||
return this._ssr.extractData()
|
||||
}
|
||||
|
||||
private makeClient () {
|
||||
return createClient({
|
||||
url: `__`,
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange({ ...urqlCacheKeys, schema: introspectionFromSchema(this.schema) }),
|
||||
this._ssr,
|
||||
executeExchange({
|
||||
schema: this.schema,
|
||||
context: this.ctx,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,37 +11,6 @@ const PATH_TO_NON_PROXIED_ERROR = resolveFromPackages('server', 'lib', 'html', '
|
||||
export class HtmlDataSource {
|
||||
constructor (private ctx: DataContext) {}
|
||||
|
||||
async fetchLaunchpadInitialData () {
|
||||
const graphql = this.ctx.graphqlClient()
|
||||
|
||||
await Promise.all([
|
||||
graphql.executeQuery('HeaderBar_HeaderBarQueryDocument', {}),
|
||||
graphql.executeQuery('MainLaunchpadQueryDocument', {}),
|
||||
])
|
||||
|
||||
return graphql.getSSRData()
|
||||
}
|
||||
|
||||
async fetchAppInitialData () {
|
||||
// run mode is not driven by GraphQL, so we don't
|
||||
// need the data from these queries.
|
||||
if (this.ctx.isRunMode) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const graphql = this.ctx.graphqlClient()
|
||||
|
||||
await Promise.all([
|
||||
graphql.executeQuery('SettingsDocument', {}),
|
||||
graphql.executeQuery('SpecPageContainerDocument', {}),
|
||||
graphql.executeQuery('SpecsPageContainerDocument', {}),
|
||||
graphql.executeQuery('HeaderBar_HeaderBarQueryDocument', {}),
|
||||
graphql.executeQuery('SideBarNavigationDocument', {}),
|
||||
])
|
||||
|
||||
return graphql.getSSRData()
|
||||
}
|
||||
|
||||
async fetchAppHtml () {
|
||||
if (process.env.CYPRESS_INTERNAL_VITE_DEV) {
|
||||
const response = await this.ctx.util.fetch(`http://localhost:${process.env.CYPRESS_INTERNAL_VITE_APP_PORT}/`, { method: 'GET' })
|
||||
@@ -90,25 +59,19 @@ export class HtmlDataSource {
|
||||
return this.ctx.fs.readFile(PATH_TO_NON_PROXIED_ERROR, 'utf-8')
|
||||
}
|
||||
|
||||
const [appHtml, appInitialData, serveConfig] = await Promise.all([
|
||||
const [appHtml, serveConfig] = await Promise.all([
|
||||
this.fetchAppHtml(),
|
||||
this.fetchAppInitialData(),
|
||||
this.makeServeConfig(),
|
||||
])
|
||||
|
||||
return this.replaceBody(appHtml, appInitialData, serveConfig)
|
||||
return this.replaceBody(appHtml, serveConfig)
|
||||
}
|
||||
|
||||
private replaceBody (html: string, initialData: object, serveConfig: object) {
|
||||
// base64 before embedding so user-supplied contents can't break out of <script>
|
||||
// https://github.com/cypress-io/cypress/issues/4952
|
||||
const base64InitialData = Buffer.from(JSON.stringify(initialData)).toString('base64')
|
||||
|
||||
private replaceBody (html: string, serveConfig: object) {
|
||||
return html.replace('<body>', `
|
||||
<body>
|
||||
<script>
|
||||
window.__RUN_MODE_SPECS__ = ${JSON.stringify(this.ctx.project.specs)}
|
||||
window.__CYPRESS_INITIAL_DATA_ENCODED__ = "${base64InitialData}";
|
||||
window.__CYPRESS_MODE__ = ${JSON.stringify(this.ctx.isRunMode ? 'run' : 'open')};
|
||||
window.__CYPRESS_CONFIG__ = ${JSON.stringify(serveConfig)};
|
||||
window.__CYPRESS_TESTING_TYPE__ = '${this.ctx.coreData.currentTestingType}'
|
||||
|
||||
@@ -90,7 +90,7 @@ export class MigrationDataSource {
|
||||
}
|
||||
|
||||
// TODO(lachlan): is this the right place to use the emitter?
|
||||
this.ctx.deref.emitter.toLaunchpad()
|
||||
this.ctx.emitter.toLaunchpad()
|
||||
}
|
||||
|
||||
const { status, watcher } = await initComponentTestingMigration(
|
||||
|
||||
@@ -6,10 +6,6 @@ import type { DataContext } from '../DataContext'
|
||||
// Require rather than import since data-context is stricter than network and there are a fair amount of errors in agent.
|
||||
const { agent } = require('@packages/network')
|
||||
|
||||
// @ts-ignore agent isn't a part of cross-fetch's API since it's not a part of the browser's fetch but it is a part of node-fetch
|
||||
// which is what will be used here
|
||||
const proxiedFetch = (input: RequestInfo, init?: RequestInit) => fetch(input, { agent, ...init })
|
||||
|
||||
/**
|
||||
* this.ctx.util....
|
||||
*
|
||||
@@ -62,7 +58,9 @@ export class UtilDataSource {
|
||||
return crypto.createHash('sha1').update(value).digest('hex')
|
||||
}
|
||||
|
||||
get fetch () {
|
||||
return proxiedFetch
|
||||
fetch (input: RequestInfo | URL, init?: RequestInit) {
|
||||
// @ts-ignore agent isn't a part of cross-fetch's API since it's not a part of the browser's fetch but it is a part of node-fetch
|
||||
// which is what will be used here
|
||||
return fetch(input, { agent, ...init })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,28 @@ const NPM_CYPRESS_REGISTRY = 'https://registry.npmjs.org/cypress'
|
||||
export class VersionsDataSource {
|
||||
private _initialLaunch: boolean
|
||||
private _currentTestingType: TestingType | null
|
||||
private _latestVersion: Promise<string>
|
||||
private _npmMetadata: Promise<Record<string, string>>
|
||||
|
||||
constructor (private ctx: DataContext) {
|
||||
this._initialLaunch = true
|
||||
this._currentTestingType = this.ctx.coreData.currentTestingType
|
||||
this._latestVersion = this.getLatestVersion()
|
||||
this._npmMetadata = this.getVersionMetadata()
|
||||
this.#ensureData()
|
||||
}
|
||||
|
||||
#ensureData () {
|
||||
let versionData = this.ctx.coreData.versionData
|
||||
|
||||
if (!versionData) {
|
||||
versionData = {
|
||||
latestVersion: this.getLatestVersion().catch((e) => pkg.version),
|
||||
npmMetadata: this.getVersionMetadata().catch((e) => ({})),
|
||||
}
|
||||
|
||||
this.ctx.update((d) => {
|
||||
d.versionData = versionData
|
||||
})
|
||||
}
|
||||
|
||||
return versionData
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +65,11 @@ export class VersionsDataSource {
|
||||
* }
|
||||
*/
|
||||
async versionData (): Promise<VersionData> {
|
||||
const [latestVersion, npmMetadata] = await Promise.all([this._latestVersion, this._npmMetadata])
|
||||
const versionData = this.#ensureData()
|
||||
const [latestVersion, npmMetadata] = await Promise.all([
|
||||
versionData.latestVersion,
|
||||
versionData.npmMetadata,
|
||||
])
|
||||
|
||||
const latestVersionMetadata: Version = {
|
||||
id: latestVersion,
|
||||
@@ -73,7 +91,11 @@ export class VersionsDataSource {
|
||||
if (this.ctx.coreData.currentTestingType !== this._currentTestingType) {
|
||||
debug('resetting latest version telemetry call due to a different testing type')
|
||||
this._currentTestingType = this.ctx.coreData.currentTestingType
|
||||
this._latestVersion = this.getLatestVersion()
|
||||
this.ctx.update((d) => {
|
||||
if (d.versionData) {
|
||||
d.versionData.latestVersion = this.getLatestVersion()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ export * from './EnvDataSource'
|
||||
export * from './ErrorDataSource'
|
||||
export * from './FileDataSource'
|
||||
export * from './GitDataSource'
|
||||
export * from './GraphQLDataSource'
|
||||
export * from './HtmlDataSource'
|
||||
export * from './MigrationDataSource'
|
||||
export * from './ProjectDataSource'
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('VersionsDataSource', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
nmiStub = sinon.stub(nmi, 'machineId')
|
||||
sinon.stub(ctx.util, 'fetch').get(() => fetchStub)
|
||||
sinon.stub(ctx.util, 'fetch').callsFake(fetchStub)
|
||||
sinon.stub(os, 'platform').returns('darwin')
|
||||
sinon.stub(os, 'arch').returns('x64')
|
||||
sinon.useFakeTimers({ now: mockNow })
|
||||
@@ -116,7 +116,7 @@ describe('VersionsDataSource', () => {
|
||||
|
||||
versionsDataSource.resetLatestVersionTelemetry()
|
||||
|
||||
const latestVersion = await privateVersionsDataSource._latestVersion
|
||||
const latestVersion = await ctx.coreData.versionData.latestVersion
|
||||
|
||||
expect(latestVersion).to.eql('16.0.0')
|
||||
})
|
||||
@@ -144,7 +144,7 @@ describe('VersionsDataSource', () => {
|
||||
|
||||
const versionInfo = await versionsDataSource.versionData()
|
||||
|
||||
expect(versionInfo.current).to.eql(versionInfo.latest)
|
||||
expect(versionInfo.current.version).to.eql(currentCypressVersion)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,65 +191,63 @@ async function makeE2ETasks () {
|
||||
|
||||
const operationCount: Record<string, number> = {}
|
||||
|
||||
sinon.stub(ctx.util, 'fetch').get(() => {
|
||||
return async (url: RequestInfo, init?: RequestInit) => {
|
||||
if (String(url).endsWith('/test-runner-graphql')) {
|
||||
const { query, variables } = JSON.parse(String(init?.body))
|
||||
const document = parse(query)
|
||||
const operationName = getOperationName(document)
|
||||
sinon.stub(ctx.util, 'fetch').callsFake(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(url).endsWith('/test-runner-graphql')) {
|
||||
const { query, variables } = JSON.parse(String(init?.body))
|
||||
const document = parse(query)
|
||||
const operationName = getOperationName(document)
|
||||
|
||||
operationCount[operationName ?? 'unknown'] = operationCount[operationName ?? 'unknown'] ?? 0
|
||||
operationCount[operationName ?? 'unknown'] = operationCount[operationName ?? 'unknown'] ?? 0
|
||||
|
||||
let result = await execute({
|
||||
operationName,
|
||||
document,
|
||||
variableValues: variables,
|
||||
schema: cloudSchema,
|
||||
rootValue: CloudRunQuery,
|
||||
contextValue: {
|
||||
__server__: ctx,
|
||||
},
|
||||
})
|
||||
let result = await execute({
|
||||
operationName,
|
||||
document,
|
||||
variableValues: variables,
|
||||
schema: cloudSchema,
|
||||
rootValue: CloudRunQuery,
|
||||
contextValue: {
|
||||
__server__: ctx,
|
||||
},
|
||||
})
|
||||
|
||||
operationCount[operationName ?? 'unknown']++
|
||||
operationCount[operationName ?? 'unknown']++
|
||||
|
||||
if (remoteGraphQLIntercept) {
|
||||
try {
|
||||
result = await remoteGraphQLIntercept({
|
||||
operationName,
|
||||
variables,
|
||||
document,
|
||||
query,
|
||||
result,
|
||||
callCount: operationCount[operationName ?? 'unknown'],
|
||||
}, testState)
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
if (remoteGraphQLIntercept) {
|
||||
try {
|
||||
result = await remoteGraphQLIntercept({
|
||||
operationName,
|
||||
variables,
|
||||
document,
|
||||
query,
|
||||
result,
|
||||
callCount: operationCount[operationName ?? 'unknown'],
|
||||
}, testState)
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
|
||||
result = { data: null, extensions: [], errors: [new GraphQLError(err.message, undefined, undefined, undefined, undefined, err)] }
|
||||
}
|
||||
result = { data: null, extensions: [], errors: [new GraphQLError(err.message, undefined, undefined, undefined, undefined, err)] }
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result), { status: 200 })
|
||||
}
|
||||
|
||||
if (String(url) === 'https://download.cypress.io/desktop.json') {
|
||||
return new Response(JSON.stringify({
|
||||
name: 'Cypress',
|
||||
version: pkg.version,
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
if (String(url) === 'https://registry.npmjs.org/cypress') {
|
||||
return new Response(JSON.stringify({
|
||||
'time': {
|
||||
[pkg.version]: '2022-02-10T01:07:37.369Z',
|
||||
},
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
return fetchApi(url, init)
|
||||
return new Response(JSON.stringify(result), { status: 200 })
|
||||
}
|
||||
|
||||
if (String(url) === 'https://download.cypress.io/desktop.json') {
|
||||
return new Response(JSON.stringify({
|
||||
name: 'Cypress',
|
||||
version: pkg.version,
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
if (String(url) === 'https://registry.npmjs.org/cypress') {
|
||||
return new Response(JSON.stringify({
|
||||
'time': {
|
||||
[pkg.version]: '2022-02-10T01:07:37.369Z',
|
||||
},
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
return fetchApi(url, init)
|
||||
})
|
||||
|
||||
return null
|
||||
|
||||
@@ -38,18 +38,18 @@
|
||||
@clear-force-open="isForceOpenAllowed = false"
|
||||
>
|
||||
<template
|
||||
v-if="!!props.gql?.cloudViewer"
|
||||
v-if="userData"
|
||||
#login-title
|
||||
>
|
||||
<UserAvatar
|
||||
:email="email"
|
||||
:email="userData?.email"
|
||||
class="h-24px w-24px"
|
||||
data-cy="user-avatar-title"
|
||||
/>
|
||||
<span class="sr-only">{{ t('topNav.login.profileMenuLabel') }}</span>
|
||||
</template>
|
||||
<template
|
||||
v-if="!!props.gql?.cloudViewer"
|
||||
v-if="userData"
|
||||
#login-panel
|
||||
>
|
||||
<div
|
||||
@@ -58,14 +58,14 @@
|
||||
>
|
||||
<div class="border-b flex border-b-gray-100 p-16px">
|
||||
<UserAvatar
|
||||
:email="email"
|
||||
:email="userData?.email"
|
||||
class="h-48px mr-16px w-48px"
|
||||
data-cy="user-avatar-panel"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-gray-800">{{ props.gql?.cloudViewer?.fullName }}</span>
|
||||
<span class="text-gray-800">{{ userData?.fullName }}</span>
|
||||
<br>
|
||||
<span class="text-gray-600">{{ props.gql?.cloudViewer?.email }}</span>
|
||||
<span class="text-gray-600">{{ userData?.email }}</span>
|
||||
<br>
|
||||
<ExternalLink
|
||||
href="https://on.cypress.io/dashboard/profile"
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</TopNav>
|
||||
<div v-if="!props.gql?.cloudViewer">
|
||||
<div v-if="!userData">
|
||||
<button
|
||||
class="flex text-gray-600 group items-center focus:outline-transparent"
|
||||
@click="openLogin"
|
||||
@@ -121,10 +121,26 @@ import ExternalLink from './ExternalLink.vue'
|
||||
import interval from 'human-interval'
|
||||
import { sortBy } from 'lodash'
|
||||
|
||||
gql`
|
||||
fragment HeaderBarContent_Auth on Query {
|
||||
cloudViewer {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
}
|
||||
cachedUser {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
subscription HeaderBarContent_authChange {
|
||||
authChange {
|
||||
...Auth
|
||||
...HeaderBarContent_Auth
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -152,9 +168,14 @@ fragment HeaderBar_HeaderBarContent on Query {
|
||||
projectRootFromCI
|
||||
...TopNav
|
||||
...Auth
|
||||
...HeaderBarContent_Auth
|
||||
}
|
||||
`
|
||||
|
||||
const userData = computed(() => {
|
||||
return props.gql.cloudViewer ?? props.gql.cachedUser
|
||||
})
|
||||
|
||||
const savedState = computed(() => {
|
||||
return props.gql?.currentProject?.savedState
|
||||
})
|
||||
@@ -164,7 +185,6 @@ const cloudProjectId = computed(() => {
|
||||
|
||||
const isLoginOpen = ref(false)
|
||||
const clearCurrentProjectMutation = useMutation(GlobalPageHeader_ClearCurrentProjectDocument)
|
||||
const email = computed(() => props.gql.cloudViewer?.email || undefined)
|
||||
|
||||
const openLogin = () => {
|
||||
isLoginOpen.value = true
|
||||
|
||||
@@ -7,6 +7,11 @@ describe('<UserAvatar />', { viewportWidth: 48, viewportHeight: 48 }, () => {
|
||||
cy.percySnapshot()
|
||||
})
|
||||
|
||||
it('renders when a null email address is passed', () => {
|
||||
cy.mount(() => <UserAvatar email={null} class="h-50px w-50px"/>)
|
||||
validateUserAvatar()
|
||||
})
|
||||
|
||||
it('renders when no email address is passed', () => {
|
||||
cy.mount(() => <UserAvatar class="h-50px w-50px"/>)
|
||||
validateUserAvatar()
|
||||
|
||||
@@ -11,7 +11,7 @@ import gravatar from 'gravatar'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
email?: string
|
||||
email?: string | null
|
||||
}>()
|
||||
|
||||
const gravatarUrl = computed(() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
dedupExchange,
|
||||
errorExchange,
|
||||
fetchExchange,
|
||||
ssrExchange,
|
||||
subscriptionExchange,
|
||||
} from '@urql/core'
|
||||
import { devtoolsExchange } from '@urql/devtools'
|
||||
@@ -20,7 +19,6 @@ import { urqlSchema } from '../generated/urql-introspection.gen'
|
||||
|
||||
import { pubSubExchange } from './urqlExchangePubsub'
|
||||
import { namedRouteExchange } from './urqlExchangeNamedRoute'
|
||||
import { decodeBase64Unicode } from '../utils/decodeBase64'
|
||||
import type { SpecFile, AutomationElementId, Browser } from '@packages/types'
|
||||
import { urqlFetchSocketAdapter } from './urqlFetchSocketAdapter'
|
||||
|
||||
@@ -38,8 +36,6 @@ declare global {
|
||||
* to use cy.intercept in tests that we need it
|
||||
*/
|
||||
__CYPRESS_GQL_NO_SOCKET__?: string
|
||||
__CYPRESS_INITIAL_DATA__: object
|
||||
__CYPRESS_INITIAL_DATA_ENCODED__: string
|
||||
__CYPRESS_MODE__: 'run' | 'open'
|
||||
__RUN_MODE_SPECS__: SpecFile[]
|
||||
__CYPRESS_TESTING_TYPE__: 'e2e' | 'component'
|
||||
@@ -53,16 +49,6 @@ declare global {
|
||||
|
||||
const cypressInRunMode = window.top === window && window.__CYPRESS_MODE__ === 'run'
|
||||
|
||||
export async function preloadLaunchpadData () {
|
||||
try {
|
||||
const resp = await fetch('/__launchpad/preload')
|
||||
|
||||
window.__CYPRESS_INITIAL_DATA__ = await resp.json()
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
interface LaunchpadUrqlClientConfig {
|
||||
target: 'launchpad'
|
||||
}
|
||||
@@ -75,13 +61,17 @@ interface AppUrqlClientConfig {
|
||||
|
||||
export type UrqlClientConfig = LaunchpadUrqlClientConfig | AppUrqlClientConfig
|
||||
|
||||
export function makeUrqlClient (config: UrqlClientConfig): Client {
|
||||
export async function makeUrqlClient (config: UrqlClientConfig): Promise<Client> {
|
||||
let hasError = false
|
||||
|
||||
const exchanges: Exchange[] = [dedupExchange]
|
||||
|
||||
const io = window.ws ?? getPubSubSource(config)
|
||||
|
||||
const connectPromise = new Promise<void>((resolve) => {
|
||||
io.once('connect', resolve)
|
||||
})
|
||||
|
||||
const socketClient = getSocketSource(config)
|
||||
|
||||
// GraphQL and urql are not used in app + run mode, so we don't add the
|
||||
@@ -120,13 +110,6 @@ export function makeUrqlClient (config: UrqlClientConfig): Client {
|
||||
}),
|
||||
// https://formidable.com/open-source/urql/docs/graphcache/errors/
|
||||
makeCacheExchange(),
|
||||
ssrExchange({
|
||||
isClient: true,
|
||||
// @ts-ignore - this seems fine locally, but on CI tsc is failing - bizarre.
|
||||
initialState: (window.__CYPRESS_INITIAL_DATA_ENCODED__
|
||||
? JSON.parse(decodeBase64Unicode(window.__CYPRESS_INITIAL_DATA_ENCODED__))
|
||||
: window.__CYPRESS_INITIAL_DATA__) || {},
|
||||
}),
|
||||
namedRouteExchange,
|
||||
fetchExchange,
|
||||
subscriptionExchange({
|
||||
@@ -151,7 +134,7 @@ export function makeUrqlClient (config: UrqlClientConfig): Client {
|
||||
|
||||
const url = config.target === 'launchpad' ? `/__launchpad/graphql` : `/${config.namespace}/graphql`
|
||||
|
||||
return createClient({
|
||||
const client = createClient({
|
||||
url,
|
||||
requestPolicy: cypressInRunMode ? 'cache-only' : 'cache-first',
|
||||
exchanges,
|
||||
@@ -161,6 +144,10 @@ export function makeUrqlClient (config: UrqlClientConfig): Client {
|
||||
// swap in-and-out during integration tests.
|
||||
fetch: config.target === 'launchpad' || window.__CYPRESS_GQL_NO_SOCKET__ ? window.fetch : urqlFetchSocketAdapter(io),
|
||||
})
|
||||
|
||||
await connectPromise
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
interface LaunchpadPubSubConfig {
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import { pipe, tap } from 'wonka'
|
||||
import type { Exchange, Operation, OperationResult } from '@urql/core'
|
||||
import type { Socket } from '@packages/socket/lib/browser'
|
||||
import type { DefinitionNode, DocumentNode, OperationDefinitionNode } from 'graphql'
|
||||
|
||||
export const pubSubExchange = (io: Socket): Exchange => {
|
||||
return ({ client, forward }) => {
|
||||
const watchedOperations = new Map<number, Operation>()
|
||||
const observedOperations = new Map<number, number>()
|
||||
// Keeps track of the operations we're expecting to re-query,
|
||||
// but which haven't resolved yet on their initial request.
|
||||
const awaitingMount: Record<string, RefreshOnlyInfo> = {}
|
||||
|
||||
io.on('data-context-push', (...args) => {
|
||||
watchedOperations.forEach((op) => {
|
||||
client.reexecuteOperation(
|
||||
client.createRequestOperation('query', op, {
|
||||
requestPolicy: 'cache-and-network',
|
||||
}),
|
||||
)
|
||||
})
|
||||
function reexecuteOperation (op: Operation, refetchHeader = 'true') {
|
||||
client.reexecuteOperation(
|
||||
client.createRequestOperation('query', op, {
|
||||
requestPolicy: 'cache-and-network',
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
'x-cypress-graphql-refetch': refetchHeader,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
interface RefreshOnlyInfo {
|
||||
operation: string
|
||||
field: string
|
||||
variables: any
|
||||
}
|
||||
|
||||
// Handles the refresh of the GraphQL operation
|
||||
io.on('graphql-refresh', (refreshOnly?: RefreshOnlyInfo) => {
|
||||
if (refreshOnly?.operation) {
|
||||
const fieldHeader = `${refreshOnly.operation}.${refreshOnly.field}`
|
||||
const toRefresh = Array.from(watchedOperations.values()).find((o) => getOperationName(o.query) === refreshOnly.operation)
|
||||
|
||||
if (!toRefresh) {
|
||||
awaitingMount[refreshOnly.operation] = refreshOnly
|
||||
} else {
|
||||
reexecuteOperation(toRefresh, fieldHeader)
|
||||
}
|
||||
} else {
|
||||
watchedOperations.forEach((op) => {
|
||||
reexecuteOperation(op)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const processIncomingOperation = (op: Operation) => {
|
||||
@@ -28,6 +59,14 @@ export const pubSubExchange = (io: Socket): Exchange => {
|
||||
if (op.operation.kind === 'query' && !observedOperations.has(op.operation.key)) {
|
||||
observedOperations.set(op.operation.key, 1)
|
||||
watchedOperations.set(op.operation.key, op.operation)
|
||||
const name = getOperationName(op.operation.query)
|
||||
|
||||
if (name && awaitingMount[name]) {
|
||||
const awaiting = awaitingMount[name]
|
||||
|
||||
delete awaitingMount[name]
|
||||
reexecuteOperation(op.operation, `${awaiting.operation}.${awaiting.field}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,3 +79,15 @@ export const pubSubExchange = (io: Socket): Exchange => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOperationName (query: DocumentNode): string | undefined {
|
||||
return getPrimaryOperation(query)?.name?.value
|
||||
}
|
||||
|
||||
function getPrimaryOperation (query: DocumentNode): OperationDefinitionNode | undefined {
|
||||
return query.definitions.find(isOperationDefinitionNode)
|
||||
}
|
||||
|
||||
function isOperationDefinitionNode (node: DefinitionNode): node is OperationDefinitionNode {
|
||||
return node.kind === 'OperationDefinition'
|
||||
}
|
||||
|
||||
@@ -50,6 +50,21 @@ enum BrowserStatus {
|
||||
opening
|
||||
}
|
||||
|
||||
"""
|
||||
When we don't have an immediate response for the cloudViewer request, we'll use this as a fallback to
|
||||
render the avatar in the header bar / signal authenticated state immediately
|
||||
"""
|
||||
type CachedUser implements Node {
|
||||
"""Email address of the cached user"""
|
||||
email: String
|
||||
|
||||
"""Name of the cached user"""
|
||||
fullName: String
|
||||
|
||||
"""Relay style Node ID field for the CachedUser field"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
"""
|
||||
A CloudOrganization represents an Organization stored in the Cypress Cloud
|
||||
"""
|
||||
@@ -1137,6 +1152,7 @@ type Query {
|
||||
"""The latest state of the auth process"""
|
||||
authState: AuthState!
|
||||
baseError: ErrorWrapper
|
||||
cachedUser: CachedUser
|
||||
|
||||
"""Returns an object conforming to the Relay spec"""
|
||||
cloudNode(
|
||||
@@ -1254,7 +1270,9 @@ type Subscription {
|
||||
"""Status of the currently opened browser"""
|
||||
browserStatusChange: CurrentProject
|
||||
|
||||
""""""
|
||||
"""
|
||||
Triggered when there is a change to the info associated with the cloud project (org added, project added)
|
||||
"""
|
||||
cloudViewerChange: Query
|
||||
|
||||
"""Issued for internal development changes"""
|
||||
|
||||
@@ -3,3 +3,5 @@ export { graphqlSchema } from './schema'
|
||||
export { execute, parse, print } from 'graphql'
|
||||
|
||||
export { remoteSchemaWrapped } from './stitching/remoteSchemaWrapped'
|
||||
|
||||
export type { RemoteExecutionRoot } from './stitching/remoteSchemaExecutor'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import express from 'express'
|
||||
import express, { Request } from 'express'
|
||||
import type { AddressInfo, Socket } from 'net'
|
||||
import { DataContext, getCtx, globalPubSub } from '@packages/data-context'
|
||||
import { DataContext, getCtx, globalPubSub, GraphQLRequestInfo } from '@packages/data-context'
|
||||
import pDefer from 'p-defer'
|
||||
import cors from 'cors'
|
||||
import { SocketIOServer } from '@packages/socket'
|
||||
import type { Server } from 'http'
|
||||
import { graphqlHTTP, GraphQLParams } from 'express-graphql'
|
||||
import { graphqlHTTP } from 'express-graphql'
|
||||
import serverDestroy from 'server-destroy'
|
||||
import send from 'send'
|
||||
import { getPathToDist } from '@packages/resolve-dist'
|
||||
@@ -15,11 +15,11 @@ import { Server as WebSocketServer } from 'ws'
|
||||
import { useServer } from 'graphql-ws/lib/use/ws'
|
||||
|
||||
import { graphqlSchema } from './schema'
|
||||
import { execute, parse } from 'graphql'
|
||||
import { DefinitionNode, DocumentNode, execute, Kind, OperationDefinitionNode, OperationTypeNode, parse } from 'graphql'
|
||||
|
||||
const debugOperation = debugLib(`cypress-verbose:graphql:operation`)
|
||||
const debug = debugLib(`cypress-verbose:graphql:operation`)
|
||||
|
||||
const SHOW_GRAPHIQL = process.env.CYPRESS_INTERNAL_ENV !== 'production'
|
||||
const IS_DEVELOPMENT = process.env.CYPRESS_INTERNAL_ENV !== 'production'
|
||||
|
||||
let gqlSocketServer: SocketIOServer
|
||||
let gqlServer: Server
|
||||
@@ -35,17 +35,6 @@ export async function makeGraphQLServer () {
|
||||
|
||||
app.use(cors())
|
||||
|
||||
app.get('/__launchpad/preload', (req, res) => {
|
||||
const ctx = getCtx()
|
||||
|
||||
ctx.html.fetchLaunchpadInitialData().then((data) => {
|
||||
res.json(data)
|
||||
}).catch((e) => {
|
||||
ctx.logTraceError(e)
|
||||
res.json({})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/cloud-notification', (req, res) => {
|
||||
const ctx = getCtx()
|
||||
|
||||
@@ -106,7 +95,7 @@ export async function makeGraphQLServer () {
|
||||
console.log(`GraphQL server is running at ${endpoint}`)
|
||||
}
|
||||
|
||||
ctx.debug(`GraphQL Server at ${endpoint}`)
|
||||
debug(`GraphQL Server at ${endpoint}`)
|
||||
|
||||
gqlServer = srv
|
||||
|
||||
@@ -153,13 +142,19 @@ interface GraphQLSocketPayload {
|
||||
export async function handleGraphQLSocketRequest (uid: string, payload: string, callback: Function) {
|
||||
try {
|
||||
const operation = JSON.parse(payload) as GraphQLSocketPayload
|
||||
|
||||
const context = getCtx()
|
||||
const document = parse(operation.query)
|
||||
const result = await execute({
|
||||
operationName: operation.operationName,
|
||||
variableValues: operation.variables,
|
||||
document: parse(operation.query),
|
||||
document,
|
||||
schema: graphqlSchema,
|
||||
contextValue: getCtx(),
|
||||
contextValue: graphqlRequestContext({
|
||||
app: 'app',
|
||||
context,
|
||||
document,
|
||||
variables: operation.variables ?? null,
|
||||
}),
|
||||
})
|
||||
|
||||
callback(result)
|
||||
@@ -195,20 +190,41 @@ export const graphqlWS = (httpServer: Server, targetRoute: string) => {
|
||||
return graphqlWs
|
||||
}
|
||||
|
||||
/**
|
||||
* An Express middleware function handler which can be added to
|
||||
* routes expected to service a GraphQL request from an HTTP client.
|
||||
*/
|
||||
export const graphQLHTTP = graphqlHTTP((req, res, params) => {
|
||||
const context = getCtx()
|
||||
const ctx = SHOW_GRAPHIQL ? maybeProxyContext(params, context) : context
|
||||
let document: DocumentNode | undefined
|
||||
|
||||
// Parse the query ahead-of-time, so we can use in the graphqlRequestContext
|
||||
try {
|
||||
// @ts-expect-error
|
||||
document = parse(params.query)
|
||||
} catch {
|
||||
// error will be re-thrown in customParseFn below
|
||||
}
|
||||
|
||||
return {
|
||||
schema: graphqlSchema,
|
||||
graphiql: SHOW_GRAPHIQL,
|
||||
context: ctx,
|
||||
graphiql: IS_DEVELOPMENT,
|
||||
context: params && document ? graphqlRequestContext({
|
||||
req: req as Request,
|
||||
context,
|
||||
document,
|
||||
variables: params.variables,
|
||||
}) : undefined,
|
||||
customParseFn: (source) => {
|
||||
// No need to re-parse if we have a document, otherwise re-parse to throw the error
|
||||
return document ?? parse(source)
|
||||
},
|
||||
customExecuteFn: (args) => {
|
||||
const date = new Date()
|
||||
const prefix = `${args.operationName ?? '(anonymous)'}`
|
||||
|
||||
return Promise.resolve(execute(args)).then((val) => {
|
||||
debugOperation(`${prefix} completed in ${new Date().valueOf() - date.valueOf()}ms with ${val.errors?.length ?? 0} errors`)
|
||||
debug(`${prefix} completed in ${new Date().valueOf() - date.valueOf()}ms with ${val.errors?.length ?? 0} errors`)
|
||||
|
||||
return val
|
||||
})
|
||||
@@ -216,35 +232,46 @@ export const graphQLHTTP = graphqlHTTP((req, res, params) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Adds runtime validations during development to ensure patterns of access are enforced
|
||||
* on the DataContext
|
||||
*/
|
||||
function maybeProxyContext (params: GraphQLParams | undefined, context: DataContext): DataContext {
|
||||
if (params?.query) {
|
||||
const parsed = parse(params.query)
|
||||
const def = parsed.definitions[0]
|
||||
|
||||
if (def?.kind === 'OperationDefinition') {
|
||||
return def.operation === 'query' ? proxyContext(context, def.name?.value ?? '(anonymous)') : context
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
interface GraphQLRequestContextOptions {
|
||||
app?: 'launchpad' | 'app'
|
||||
req?: Request
|
||||
context: DataContext
|
||||
document: DocumentNode
|
||||
variables: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function proxyContext (ctx: DataContext, operationName: string) {
|
||||
return new Proxy(ctx, {
|
||||
/**
|
||||
* Since the DataContext is considered a singleton throughout the electron app process,
|
||||
* we create a Proxy object for it, adding metadata associated each GraphQL operation.
|
||||
* This is used in middleware, such as the `nexusDeferIfNotLoadedPlugin`, to associate
|
||||
* remote requests to operations needing to be refetched on the client.
|
||||
*/
|
||||
function graphqlRequestContext (options: GraphQLRequestContextOptions) {
|
||||
const app = options.app ?? (options.req?.originalUrl.startsWith('/__launchpad') ? 'launchpad' : 'app')
|
||||
|
||||
const primaryOperation = getPrimaryOperation(options.document)
|
||||
|
||||
const requestInfo: GraphQLRequestInfo = {
|
||||
app,
|
||||
operation: (primaryOperation?.kind ?? 'query') as OperationTypeNode,
|
||||
document: options.document,
|
||||
headers: options.req?.headers ?? {},
|
||||
variables: options.variables,
|
||||
operationName: primaryOperation?.name?.value ?? null,
|
||||
}
|
||||
|
||||
debug('Creating context for %s, operation %s', app, primaryOperation?.name?.value)
|
||||
|
||||
return new Proxy(options.context, {
|
||||
get (target, p, receiver) {
|
||||
// Allows us to get the context value, deref'ed so it's not guarded
|
||||
if (p === 'deref') {
|
||||
return Reflect.get(ctx, 'deref', ctx)
|
||||
if (p === 'graphqlRequestInfo') {
|
||||
return requestInfo
|
||||
}
|
||||
|
||||
if (p === 'actions' || p === 'emitter') {
|
||||
if (p === 'actions' && IS_DEVELOPMENT && requestInfo.operation === 'query') {
|
||||
throw new Error(
|
||||
`Cannot access ctx.${p} within a query, only within mutations / outside of a GraphQL request\n` +
|
||||
`Seen in operation: ${operationName}`,
|
||||
`Seen in operation: ${requestInfo.operationName}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,3 +279,11 @@ function proxyContext (ctx: DataContext, operationName: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getPrimaryOperation (query: DocumentNode): OperationDefinitionNode | undefined {
|
||||
return query.definitions.find(isOperationDefinitionNode)
|
||||
}
|
||||
|
||||
function isOperationDefinitionNode (node: DefinitionNode): node is OperationDefinitionNode {
|
||||
return node.kind === Kind.OPERATION_DEFINITION
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// created by autobarrel, do not modify directly
|
||||
|
||||
export * from './nexusDebugFieldPlugin'
|
||||
export * from './nexusDeferIfNotLoadedPlugin'
|
||||
export * from './nexusMutationErrorPlugin'
|
||||
export * from './nexusNodePlugin'
|
||||
export * from './nexusSlowGuardPlugin'
|
||||
|
||||
122
packages/graphql/src/plugins/nexusDeferIfNotLoadedPlugin.ts
Normal file
122
packages/graphql/src/plugins/nexusDeferIfNotLoadedPlugin.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { plugin } from 'nexus'
|
||||
import debugLib from 'debug'
|
||||
import { getNamedType, isNonNullType } from 'graphql'
|
||||
import type { DataContext } from '@packages/data-context'
|
||||
import { remoteSchema } from '../stitching/remoteSchema'
|
||||
|
||||
const NO_RESULT = {}
|
||||
// 2ms should be enough time to resolve from the local cache of the
|
||||
// cloudUrqlClient in CloudDataSource
|
||||
const RACE_MAX_EXECUTION_MS = 2
|
||||
const IS_DEVELOPMENT = process.env.CYPRESS_INTERNAL_ENV !== 'production'
|
||||
const debug = debugLib('cypress:graphql:nexusDeferIfNotLoadedPlugin')
|
||||
|
||||
/**
|
||||
* This plugin taps into each of the requests and checks for the existence
|
||||
* of a "Cloud" prefixed type. When we see these, we know that we're dealing
|
||||
* with a remote API. We can also specify `deferIfNotLoaded: true` on the Nexus definition
|
||||
* to indicate that this is a remote request, such as resolving the "versions" field
|
||||
*/
|
||||
export const nexusDeferIfNotLoadedPlugin = plugin({
|
||||
name: 'nexusDeferIfNotLoadedPlugin',
|
||||
fieldDefTypes: 'deferIfNotLoaded?: true',
|
||||
onCreateFieldResolver (def) {
|
||||
const { name: parentTypeName } = def.parentTypeConfig
|
||||
|
||||
// Don't ever need to do this on Subscription / Mutation fields.
|
||||
if (parentTypeName === 'Mutation' || parentTypeName === 'Subscription') {
|
||||
return
|
||||
}
|
||||
|
||||
// Also don't need to if the type is in the cloud schema, (and isn't a Query) since these don't
|
||||
// actually need to resolve themselves, they're resolved from the remote request
|
||||
if (parentTypeName !== 'Query' && remoteSchema.getType(parentTypeName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Specified w/ deferIfNotLoaded: true on the field definition
|
||||
const shouldDeferIfNotLoaded = Boolean(def.fieldConfig.extensions?.nexus?.config.deferIfNotLoaded)
|
||||
|
||||
// Fields where type: 'Cloud*', e.g. 'cloudViewer' which is type: 'CloudUser'
|
||||
const isEligibleCloudField = getNamedType(def.fieldConfig.type).name.startsWith('Cloud')
|
||||
|
||||
if (!isEligibleCloudField && !shouldDeferIfNotLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
const qualifiedField = `${def.parentTypeConfig.name}.${def.fieldConfig.name}`
|
||||
|
||||
// We should never allow a non-null query type, this is an error should be caught at development time
|
||||
if (isNonNullType(def.fieldConfig.type)) {
|
||||
throw new Error(`Cannot add nexusDeferIfNotLoadedPlugin to non-nullable field ${qualifiedField}`)
|
||||
}
|
||||
|
||||
debug(`Adding nexusDeferIfNotLoadedPlugin for %s`, qualifiedField)
|
||||
|
||||
return async (source, args, ctx: DataContext, info, next) => {
|
||||
// Don't need to race Mutations / Subscriptions, which can return types containing these fields
|
||||
// these can just call through and don't need to be resolved immediately, because there's an expectation
|
||||
// of potential delay built-in to these contracts
|
||||
if (
|
||||
info.operation.operation === 'mutation' ||
|
||||
info.operation.operation === 'subscription'
|
||||
) {
|
||||
return next(source, args, ctx, info)
|
||||
}
|
||||
|
||||
debug(`Racing execution for %s`, qualifiedField)
|
||||
|
||||
let didRace = false
|
||||
|
||||
const raceResult: unknown = await Promise.race([
|
||||
new Promise((resolve) => setTimeout(() => resolve(NO_RESULT), RACE_MAX_EXECUTION_MS)),
|
||||
Promise.resolve(next(source, args, ctx, info)).then((result) => {
|
||||
if (!didRace) {
|
||||
debug(`Racing %s resolved immediately`, qualifiedField)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
debug(`Racing %s eventually resolved with %o`, qualifiedField, result, ctx.graphqlRequestInfo?.operationName)
|
||||
|
||||
// If we raced the query, and this looks like a client request we can re-execute,
|
||||
// we will look to do so.
|
||||
if (ctx.graphqlRequestInfo?.operationName) {
|
||||
// We don't want to notify the client if we see a refetch header, and we want to warn if
|
||||
// we raced twice, as this means we're not caching the data properly
|
||||
if (ctx.graphqlRequestInfo.headers['x-cypress-graphql-refetch']) {
|
||||
// If we've hit this during a refetch, but the refetch was unrelated to the original request,
|
||||
// that's fine, it just means that we might receive a notification to refetch in the future for the other field
|
||||
if (IS_DEVELOPMENT && ctx.graphqlRequestInfo.headers['x-cypress-graphql-refetch'] === `${ctx.graphqlRequestInfo?.operationName}.${qualifiedField}`) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(new Error(`
|
||||
It looks like we hit the Promise.race while re-executing the operation ${ctx.graphqlRequestInfo.operationName}
|
||||
this means that we sent the client a signal to refetch, but the data wasn't stored when it did.
|
||||
This likely means we're not caching the result of the the data properly.
|
||||
`))
|
||||
}
|
||||
} else {
|
||||
debug(`Notifying app %s, %s of updated field %s`, ctx.graphqlRequestInfo.app, ctx.graphqlRequestInfo.operationName, qualifiedField)
|
||||
ctx.emitter.notifyClientRefetch(ctx.graphqlRequestInfo.app, ctx.graphqlRequestInfo.operationName, qualifiedField, ctx.graphqlRequestInfo.variables)
|
||||
}
|
||||
} else {
|
||||
debug(`No operation to notify of result for %s`, qualifiedField)
|
||||
}
|
||||
}).catch((e) => {
|
||||
debug(`Remote execution error %o`, e)
|
||||
|
||||
return null
|
||||
}),
|
||||
])
|
||||
|
||||
if (raceResult === NO_RESULT) {
|
||||
debug(`%s did not resolve immediately`, qualifiedField)
|
||||
didRace = true
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return raceResult
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { plugin } from 'nexus'
|
||||
import { isPromiseLike, pathToArray } from 'nexus/dist/utils'
|
||||
import chalk from 'chalk'
|
||||
|
||||
const HANGING_RESOLVER_THRESHOLD = 2000
|
||||
const HANGING_RESOLVER_THRESHOLD = 15
|
||||
|
||||
export const nexusSlowGuardPlugin = plugin({
|
||||
name: 'NexusSlowGuard',
|
||||
@@ -12,9 +12,12 @@ export const nexusSlowGuardPlugin = plugin({
|
||||
onCreateFieldResolver (field) {
|
||||
const threshold = (field.fieldConfig.extensions?.nexus?.config.slowLogThreshold ?? HANGING_RESOLVER_THRESHOLD) as number | false
|
||||
|
||||
// For fields, we only want to log if the field takes longer than SLOW_FIELD_THRESHOLD to execute.
|
||||
// Also log if it's hanging for some reason
|
||||
return (root, args, ctx, info, next) => {
|
||||
// Don't worry about slowness in Mutations / Subscriptions, these aren't blocking the execution of initial load
|
||||
if (info.operation.operation === 'mutation' || info.operation.operation === 'subscription') {
|
||||
return next(root, args, ctx, info)
|
||||
}
|
||||
|
||||
const result = next(root, args, ctx, info)
|
||||
|
||||
if (isPromiseLike(result) && threshold !== false) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { makeSchema, connectionPlugin } from 'nexus'
|
||||
import * as schemaTypes from './schemaTypes/'
|
||||
import { nodePlugin } from './plugins/nexusNodePlugin'
|
||||
import { remoteSchemaWrapped } from './stitching/remoteSchemaWrapped'
|
||||
import { mutationErrorPlugin, nexusDebugLogPlugin, nexusSlowGuardPlugin } from './plugins'
|
||||
import { mutationErrorPlugin, nexusDebugLogPlugin, nexusSlowGuardPlugin, nexusDeferIfNotLoadedPlugin } from './plugins'
|
||||
|
||||
const isCodegen = Boolean(process.env.CYPRESS_INTERNAL_NEXUS_CODEGEN)
|
||||
|
||||
@@ -31,6 +31,7 @@ export const graphqlSchema = makeSchema({
|
||||
},
|
||||
plugins: [
|
||||
nexusSlowGuardPlugin,
|
||||
nexusDeferIfNotLoadedPlugin,
|
||||
nexusDebugLogPlugin,
|
||||
mutationErrorPlugin,
|
||||
connectionPlugin({
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import dedent from 'dedent'
|
||||
import { objectType } from 'nexus'
|
||||
|
||||
export const CachedUser = objectType({
|
||||
name: 'CachedUser',
|
||||
description: dedent`
|
||||
When we don't have an immediate response for the cloudViewer request, we'll use this as a fallback to
|
||||
render the avatar in the header bar / signal authenticated state immediately
|
||||
`,
|
||||
node: 'email',
|
||||
definition (t) {
|
||||
t.string('fullName', {
|
||||
description: 'Name of the cached user',
|
||||
resolve: (source) => source.name ?? null,
|
||||
})
|
||||
|
||||
t.string('email', {
|
||||
description: 'Email address of the cached user',
|
||||
})
|
||||
},
|
||||
sourceType: {
|
||||
export: 'AuthenticatedUserShape',
|
||||
module: '@packages/data-context/src/data/coreDataShape',
|
||||
},
|
||||
})
|
||||
@@ -34,7 +34,7 @@ export const ErrorWrapper = objectType({
|
||||
t.nonNull.string('errorMessage', {
|
||||
description: 'The markdown formatted content associated with the ErrorTypeEnum',
|
||||
resolve (source) {
|
||||
return source.cypressError.messageMarkdown
|
||||
return source.cypressError.messageMarkdown ?? source.cypressError.message
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -258,7 +258,6 @@ export const mutation = mutationType({
|
||||
|
||||
t.field('login', {
|
||||
type: Query,
|
||||
slowLogThreshold: false,
|
||||
description: 'Auth with Cypress Dashboard',
|
||||
resolve: async (_, args, ctx) => {
|
||||
await ctx.actions.auth.login()
|
||||
@@ -279,7 +278,6 @@ export const mutation = mutationType({
|
||||
|
||||
t.field('launchOpenProject', {
|
||||
type: CurrentProject,
|
||||
slowLogThreshold: false,
|
||||
description: 'Launches project from open_project global singleton',
|
||||
args: {
|
||||
specPath: stringArg(),
|
||||
@@ -352,7 +350,7 @@ export const mutation = mutationType({
|
||||
async resolve (_, args, ctx) {
|
||||
await ctx.actions.project.setProjectPreferences(args)
|
||||
|
||||
return ctx.appData
|
||||
return {}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -559,7 +557,6 @@ export const mutation = mutationType({
|
||||
t.field('migrateConfigFile', {
|
||||
description: 'Transforms cypress.json file into cypress.config.js file',
|
||||
type: Query,
|
||||
slowLogThreshold: 5000, // This mutation takes a little time
|
||||
resolve: async (_, args, ctx) => {
|
||||
await ctx.actions.migration.createConfigFile()
|
||||
await ctx.actions.migration.nextStep()
|
||||
@@ -634,7 +631,7 @@ export const mutation = mutationType({
|
||||
await ctx.actions.project.reconfigureProject()
|
||||
}
|
||||
|
||||
return true
|
||||
return {}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Migration } from './gql-Migration'
|
||||
import { VersionData } from './gql-VersionData'
|
||||
import { Wizard } from './gql-Wizard'
|
||||
import { ErrorWrapper } from './gql-ErrorWrapper'
|
||||
import { CachedUser } from './gql-CachedUser'
|
||||
|
||||
export const Query = objectType({
|
||||
name: 'Query',
|
||||
@@ -18,6 +19,11 @@ export const Query = objectType({
|
||||
resolve: (root, args, ctx) => ctx.baseError,
|
||||
})
|
||||
|
||||
t.field('cachedUser', {
|
||||
type: CachedUser,
|
||||
resolve: (root, args, ctx) => ctx.user,
|
||||
})
|
||||
|
||||
t.nonNull.list.nonNull.field('warnings', {
|
||||
type: ErrorWrapper,
|
||||
description: 'A list of warnings',
|
||||
@@ -45,6 +51,7 @@ export const Query = objectType({
|
||||
})
|
||||
|
||||
t.field('versions', {
|
||||
deferIfNotLoaded: true,
|
||||
type: VersionData,
|
||||
description: 'Previous versions of cypress and their release date',
|
||||
resolve: (root, args, ctx) => {
|
||||
@@ -100,4 +107,8 @@ export const Query = objectType({
|
||||
resolve: (_, args, ctx) => ctx.coreData.scaffoldedFiles,
|
||||
})
|
||||
},
|
||||
sourceType: {
|
||||
module: '@packages/graphql',
|
||||
export: 'RemoteExecutionRoot',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -20,12 +20,12 @@ export const Subscription = subscriptionType({
|
||||
|
||||
t.field('cloudViewerChange', {
|
||||
type: Query,
|
||||
description: '',
|
||||
description: 'Triggered when there is a change to the info associated with the cloud project (org added, project added)',
|
||||
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('cloudViewerChange'),
|
||||
resolve: (source, args, ctx) => {
|
||||
return {
|
||||
requestPolicy: 'network-only',
|
||||
}
|
||||
} as const
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
export * from './gql-AuthState'
|
||||
export * from './gql-Browser'
|
||||
export * from './gql-CachedUser'
|
||||
export * from './gql-CodeFrame'
|
||||
export * from './gql-CodeGenGlobs'
|
||||
export * from './gql-CurrentProject'
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DocumentNode, print } from 'graphql'
|
||||
|
||||
import type { DataContext } from '@packages/data-context'
|
||||
import type { RequestPolicy } from '@urql/core'
|
||||
|
||||
export interface RemoteExecutionRoot {
|
||||
requestPolicy?: RequestPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a "document" and executes it against the GraphQL schema
|
||||
@@ -16,12 +21,14 @@ export const remoteSchemaExecutor = async (obj: Record<string, any>) => {
|
||||
return { data: null }
|
||||
}
|
||||
|
||||
const requestPolicy: RequestPolicy | undefined = rootValue?.requestPolicy ?? null
|
||||
|
||||
const executorResult = await context.cloud.executeRemoteGraphQL({
|
||||
operationType,
|
||||
document,
|
||||
variables,
|
||||
query: print(document),
|
||||
requestPolicy: rootValue?.requestPolicy,
|
||||
requestPolicy,
|
||||
})
|
||||
|
||||
context.debug('executorResult %o', executorResult)
|
||||
|
||||
52
packages/launchpad/cypress/e2e/slow-network.cy.ts
Normal file
52
packages/launchpad/cypress/e2e/slow-network.cy.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type Sinon from 'sinon'
|
||||
|
||||
describe('slow network: launchpad', () => {
|
||||
beforeEach(() => {
|
||||
cy.scaffoldProject('todos')
|
||||
cy.withCtx((ctx, o) => {
|
||||
const currentStubbbedFetch = ctx.util.fetch;
|
||||
|
||||
(ctx.util.fetch as Sinon.SinonStub).restore()
|
||||
o.testState.pendingFetches = []
|
||||
o.sinon.stub(ctx.util, 'fetch').callsFake(async (input, init) => {
|
||||
const dfd = o.pDefer()
|
||||
|
||||
o.testState.pendingFetches.push(dfd)
|
||||
const result = await currentStubbbedFetch(input, init)
|
||||
|
||||
setTimeout(dfd.resolve, 60000)
|
||||
await dfd.promise
|
||||
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
cy.openProject('todos')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cy.withCtx(async (ctx, o) => {
|
||||
o.testState.pendingFetches.map((f) => f.resolve())
|
||||
})
|
||||
})
|
||||
|
||||
it('loads through to the browser screen when the network is slow', () => {
|
||||
cy.loginUser()
|
||||
cy.visitLaunchpad()
|
||||
cy.get('[data-cy=top-nav-cypress-version-current-link]').should('not.exist')
|
||||
cy.contains('E2E Testing').click()
|
||||
cy.get('h1').should('contain', 'Choose a Browser')
|
||||
})
|
||||
|
||||
it('shows the versions after they resolve', () => {
|
||||
cy.visitLaunchpad()
|
||||
cy.get('[data-cy=top-nav-cypress-version-current-link]').should('not.exist')
|
||||
cy.contains('Log In')
|
||||
cy.withCtx(async (ctx, o) => {
|
||||
o.testState.pendingFetches.map((f) => f.resolve())
|
||||
})
|
||||
|
||||
// This will show up after it resolves
|
||||
cy.get('[data-cy=top-nav-cypress-version-current-link]')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { HeaderBar_HeaderBarQueryDocument } from './generated/graphql'
|
||||
import './main.scss'
|
||||
import 'virtual:windi.css'
|
||||
import type { Client } from '@urql/vue'
|
||||
import urql from '@urql/vue'
|
||||
import App from './App.vue'
|
||||
import Toast, { POSITION } from 'vue-toastification'
|
||||
import 'vue-toastification/dist/index.css'
|
||||
import { makeUrqlClient, preloadLaunchpadData } from '@packages/frontend-shared/src/graphql/urqlClient'
|
||||
import { makeUrqlClient } from '@packages/frontend-shared/src/graphql/urqlClient'
|
||||
import { createI18n } from '@cy/i18n'
|
||||
import { initHighlighter } from '@cy/components/ShikiHighlight.vue'
|
||||
|
||||
@@ -20,17 +20,20 @@ app.use(Toast, {
|
||||
|
||||
app.use(createI18n())
|
||||
|
||||
let launchpadClient: Client
|
||||
|
||||
// Make sure highlighter is initialized before
|
||||
// we show any code to avoid jank at rendering
|
||||
Promise.all([
|
||||
// @ts-ignore
|
||||
initHighlighter(),
|
||||
preloadLaunchpadData(),
|
||||
]).then(() => {
|
||||
launchpadClient = makeUrqlClient({ target: 'launchpad' })
|
||||
app.use(urql, launchpadClient)
|
||||
makeUrqlClient({ target: 'launchpad' }).then((launchpadClient) => {
|
||||
app.use(urql, launchpadClient)
|
||||
|
||||
// Loading the Header Bar Query document prior to mounting leads to a better experience
|
||||
// when doing things like taking snapshots of the DOM during testing, and it
|
||||
// shouldn't be any different to the user
|
||||
launchpadClient
|
||||
.query(HeaderBar_HeaderBarQueryDocument)
|
||||
.toPromise()
|
||||
}),
|
||||
// Make sure highlighter is initialized immediately at app
|
||||
// start, so it's available when we render code blocks
|
||||
initHighlighter(),
|
||||
]).then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
@@ -69,8 +69,6 @@ gulp.task(
|
||||
'codegen',
|
||||
),
|
||||
|
||||
killExistingCypress,
|
||||
|
||||
// Now that we have the codegen, we can start the frontend(s)
|
||||
gulp.parallel(
|
||||
viteApp,
|
||||
@@ -101,6 +99,8 @@ gulp.task(
|
||||
gulp.series(
|
||||
'dev:watch',
|
||||
|
||||
killExistingCypress,
|
||||
|
||||
// And we're finally ready for electron, watching for changes in
|
||||
// /graphql to auto-restart the server
|
||||
startCypressWatch,
|
||||
|
||||
Reference in New Issue
Block a user