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:
Tim Griesser
2022-04-14 12:01:29 -04:00
committed by GitHub
parent aa98f2e3e2
commit b0c8db3434
45 changed files with 635 additions and 375 deletions

View File

@@ -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

View File

@@ -1,3 +1,4 @@
module.exports = {
spec: 'test/**/*.spec.ts',
timeout: 10000,
}

View File

@@ -1,3 +1,4 @@
module.exports = {
spec: 'test/**/*.spec.ts',
timeout: 10000,
}

View File

@@ -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')

View File

@@ -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', () => {

View File

@@ -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')
})
})

View File

@@ -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')
})

View File

@@ -131,7 +131,7 @@ query SideBarNavigation {
}
`
const query = useQuery({ query: SideBarNavigationDocument, requestPolicy: 'network-only' })
const query = useQuery({ query: SideBarNavigationDocument })
const setPreferences = useMutation(SideBarNavigation_SetPreferencesDocument)

View File

@@ -21,10 +21,6 @@
@showCreateSpecModal="showCreateSpecModal"
/>
</div>
<div v-else>
Loading...
</div>
</template>
<script lang="ts" setup>

View File

@@ -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(),
]

View File

@@ -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,
})
}
/**

View File

@@ -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')

View File

@@ -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,
}
}

View File

@@ -6,6 +6,7 @@ export {
export type {
DataContextConfig,
GraphQLRequestInfo,
} from './DataContext'
export type {

View File

@@ -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) {

View File

@@ -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,
}),
],
})
}
}

View File

@@ -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}'

View File

@@ -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(

View File

@@ -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 })
}
}

View File

@@ -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()
}
})
}
}

View File

@@ -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'

View File

@@ -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)
})
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -11,7 +11,7 @@ import gravatar from 'gravatar'
import { computed } from 'vue'
const props = defineProps<{
email?: string
email?: string | null
}>()
const gravatarUrl = computed(() => {

View File

@@ -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 {

View File

@@ -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'
}

View File

@@ -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"""

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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'

View 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
}
},
})

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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',
},
})

View File

@@ -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
},
})

View File

@@ -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 {}
},
})

View File

@@ -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',
},
})

View File

@@ -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
},
})

View File

@@ -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'

View File

@@ -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)

View 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]')
})
})

View File

@@ -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')
})

View File

@@ -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,