Skip to content

Commit 05887aa

Browse files
authored
feat: Adding a cache layer for remote queries, e2e test helpers (#18652)
1 parent bfafb8b commit 05887aa

File tree

18 files changed

+296
-64
lines changed

18 files changed

+296
-64
lines changed

packages/data-context/package.json

+7-3
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@
1313
"test-integration": "mocha -r @packages/ts/register test/integration/**/*.spec.ts --config ./test/.mocharc.js --exit"
1414
},
1515
"dependencies": {
16-
"dataloader": "^2.0.0"
16+
"@storybook/csf-tools": "^6.4.0-alpha.38",
17+
"cross-fetch": "^3.1.4",
18+
"dataloader": "^2.0.0",
19+
"globby": "^11.0.1",
20+
"lodash": "4.17.21",
21+
"p-defer": "^3.0.0",
22+
"wonka": "^4.0.15"
1723
},
1824
"devDependencies": {
1925
"@packages/ts": "0.0.0-development",
2026
"@packages/types": "0.0.0-development",
21-
"@storybook/csf-tools": "^6.4.0-alpha.38",
2227
"create-cypress-tests": "0.0.0-development",
23-
"globby": "^11.0.1",
2428
"mocha": "7.0.1",
2529
"rimraf": "3.0.2"
2630
},

packages/data-context/src/DataContext.ts

+18
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
BrowserDataSource,
1717
UtilDataSource,
1818
StorybookDataSource,
19+
CloudDataSource,
1920
} from './sources/'
2021
import { cached } from './util/cached'
2122
import { DataContextShell, DataContextShellConfig } from './DataContextShell'
@@ -63,6 +64,8 @@ export class DataContext extends DataContextShell {
6364
this.actions.app.refreshBrowsers(),
6465
// load projects from cache on start
6566
this.actions.project.loadProjects(),
67+
// load the cached user & validate the token on start
68+
this.actions.auth.getUser(),
6669
]
6770

6871
if (this.config.launchArgs.projectRoot) {
@@ -172,6 +175,11 @@ export class DataContext extends DataContextShell {
172175
return new ProjectDataSource(this)
173176
}
174177

178+
@cached
179+
get cloud () {
180+
return new CloudDataSource(this)
181+
}
182+
175183
get projectsList () {
176184
return this.coreData.app.projects
177185
}
@@ -210,6 +218,16 @@ export class DataContext extends DataContextShell {
210218
console.error(e)
211219
}
212220

221+
/**
222+
* If we really want to get around the guards added in proxyContext
223+
* which disallow referencing ctx.actions / ctx.emitter from contexct for a GraphQL query,
224+
* we can call ctx.deref.emitter, etc. This should only be used in exceptional situations where
225+
* we're certain this is a good idea.
226+
*/
227+
get deref () {
228+
return this
229+
}
230+
213231
async destroy () {
214232
super.destroy()
215233

packages/data-context/src/actions/AuthActions.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,40 @@ import type { AuthenticatedUserShape } from '../data'
33

44
interface AuthMessage {type: string, browserOpened: boolean, name: string, message: string}
55
export interface AuthApiShape {
6+
getUser(): Promise<Partial<AuthenticatedUserShape>>
67
logIn(onMessage: (message: AuthMessage) => void): Promise<AuthenticatedUserShape>
78
logOut(): Promise<void>
8-
checkAuth(context: DataContext): Promise<void>
99
}
1010

1111
export class AuthActions {
1212
constructor (private ctx: DataContext) {}
1313

14+
async getUser () {
15+
return this.authApi.getUser().then((obj) => {
16+
if (obj.authToken) {
17+
this.ctx.coreData.user = obj
18+
// When we get the user at startup, check the auth by
19+
// hitting the network
20+
this.checkAuth()
21+
}
22+
})
23+
}
24+
1425
get authApi () {
1526
return this.ctx._apis.authApi
1627
}
1728

1829
async checkAuth () {
19-
return this.authApi.checkAuth(this.ctx)
30+
const result = await this.ctx.cloud.executeRemoteGraphQL({
31+
query: `{ cloudViewer { id } }`,
32+
variables: {},
33+
requestPolicy: 'network-only',
34+
})
35+
36+
if (!result.data?.cloudViewer) {
37+
this.ctx.coreData.user = null
38+
this.logout()
39+
}
2040
}
2141

2242
async login () {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { DataContext } from '..'
2+
import pDefer from 'p-defer'
3+
import getenv from 'getenv'
4+
import { pipe, subscribe, toPromise } from 'wonka'
5+
import type { DocumentNode } from 'graphql'
6+
import {
7+
createClient,
8+
cacheExchange,
9+
dedupExchange,
10+
fetchExchange,
11+
Client,
12+
createRequest,
13+
OperationResult,
14+
RequestPolicy,
15+
} from '@urql/core'
16+
import _ from 'lodash'
17+
18+
const cloudEnv = getenv('CYPRESS_INTERNAL_CLOUD_ENV', process.env.CYPRESS_INTERNAL_ENV || 'development') as keyof typeof REMOTE_SCHEMA_URLS
19+
20+
const REMOTE_SCHEMA_URLS = {
21+
staging: 'https://dashboard-staging.cypress.io',
22+
development: 'http://localhost:3000',
23+
production: 'https://dashboard.cypress.io',
24+
}
25+
26+
export interface CloudExecuteRemote {
27+
query: string
28+
document?: DocumentNode
29+
variables: any
30+
requestPolicy?: RequestPolicy
31+
}
32+
33+
export class CloudDataSource {
34+
private _urqlClient: Client
35+
36+
constructor (private ctx: DataContext) {
37+
this._urqlClient = createClient({
38+
url: `${REMOTE_SCHEMA_URLS[cloudEnv]}/test-runner-graphql`,
39+
exchanges: [
40+
dedupExchange,
41+
cacheExchange,
42+
fetchExchange,
43+
],
44+
fetch: this.ctx.util.fetch,
45+
})
46+
}
47+
48+
async executeRemoteGraphQL (config: CloudExecuteRemote): Promise<Partial<OperationResult>> {
49+
if (!this.ctx.user) {
50+
return { data: null }
51+
}
52+
53+
const requestPolicy = config.requestPolicy ?? 'cache-and-network'
54+
55+
const executingQuery = this._urqlClient.executeQuery(createRequest(config.query, config.variables), {
56+
fetch: this.ctx.util.fetch,
57+
requestPolicy,
58+
fetchOptions: {
59+
headers: {
60+
'Content-Type': 'application/json',
61+
'Authorization': `bearer ${this.ctx.user.authToken}`,
62+
},
63+
},
64+
})
65+
66+
if (requestPolicy === 'cache-and-network') {
67+
let resolvedData: OperationResult | undefined = undefined
68+
const dfd = pDefer<OperationResult>()
69+
const pipeline = pipe(
70+
executingQuery,
71+
subscribe((res) => {
72+
if (!resolvedData) {
73+
resolvedData = res
74+
dfd.resolve(res)
75+
} else if (!_.isEqual(resolvedData.data, res.data) || !_.isEqual(resolvedData.error, res.error)) {
76+
// TODO(tim): send a signal to the frontend so when it refetches it does 'cache-only' request,
77+
// since we know we're up-to-date
78+
this.ctx.deref.emitter.toApp()
79+
this.ctx.deref.emitter.toLaunchpad()
80+
}
81+
82+
if (!res.stale) {
83+
pipeline.unsubscribe()
84+
}
85+
}),
86+
)
87+
88+
return dfd.promise
89+
}
90+
91+
return pipe(executingQuery, toPromise)
92+
}
93+
}

packages/data-context/src/sources/UtilDataSource.ts

+11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import DataLoader from 'dataloader'
2+
import crypto from 'crypto'
3+
import fetch from 'cross-fetch'
4+
25
import type { DataContext } from '..'
36

47
/**
@@ -41,4 +44,12 @@ export class UtilDataSource {
4144
loader.clearAll()
4245
}
4346
}
47+
48+
sha1 (value: string) {
49+
return crypto.createHash('sha1').update(value).digest('hex')
50+
}
51+
52+
get fetch () {
53+
return fetch
54+
}
4455
}

packages/data-context/src/sources/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
export * from './AppDataSource'
55
export * from './BrowserDataSource'
6+
export * from './CloudDataSource'
67
export * from './FileDataSource'
78
export * from './GitDataSource'
89
export * from './ProjectDataSource'

packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts

+59-4
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
1+
import path from 'path'
2+
import type { RemoteGraphQLInterceptor, WithCtxInjected, WithCtxOptions } from './support/e2eSupport'
3+
import { e2eProjectDirs } from './support/e2eProjectDirs'
4+
import type { CloudExecuteRemote } from '@packages/data-context/src/sources'
15
import type { DataContext } from '@packages/data-context'
26
import * as inspector from 'inspector'
37
import sinonChai from '@cypress/sinon-chai'
48
import sinon from 'sinon'
59
import rimraf from 'rimraf'
610
import util from 'util'
11+
import fs from 'fs'
12+
import { buildSchema, execute, parse } from 'graphql'
13+
import { Response } from 'cross-fetch'
14+
15+
import { CloudRunQuery } from '../support/mock-graphql/stubgql-CloudTypes'
16+
import { getOperationName } from '@urql/core'
17+
18+
const cloudSchema = buildSchema(fs.readFileSync(path.join(__dirname, '../../../graphql/schemas/cloud.graphql'), 'utf8'))
719

820
// require'd so we don't conflict with globals loaded in @packages/types
921
const chai = require('chai')
1022
const chaiAsPromised = require('chai-as-promised')
1123
const chaiSubset = require('chai-subset')
24+
1225
const { expect } = chai
1326

1427
chai.use(chaiAsPromised)
1528
chai.use(chaiSubset)
1629
chai.use(sinonChai)
1730

18-
import path from 'path'
19-
import type { WithCtxInjected, WithCtxOptions } from './support/e2eSupport'
20-
import { e2eProjectDirs } from './support/e2eProjectDirs'
21-
2231
export async function e2ePluginSetup (projectRoot: string, on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) {
2332
process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF = 'true'
2433
// require'd so we don't import the types from @packages/server which would
@@ -35,14 +44,22 @@ export async function e2ePluginSetup (projectRoot: string, on: Cypress.PluginEve
3544
fn: string
3645
options: WithCtxOptions
3746
activeTestId: string
47+
// TODO(tim): add an API for intercepting this
48+
interceptCloudExecute: (config: CloudExecuteRemote) => {}
3849
}
3950

4051
let ctx: DataContext
4152
let serverPortPromise: Promise<number>
4253
let currentTestId: string | undefined
4354
let testState: Record<string, any> = {}
55+
let remoteGraphQLIntercept: RemoteGraphQLInterceptor | undefined
4456

4557
on('task', {
58+
remoteGraphQLIntercept (fn: string) {
59+
remoteGraphQLIntercept = new Function('console', 'obj', `return (${fn})(obj)`).bind(null, console) as RemoteGraphQLInterceptor
60+
61+
return null
62+
},
4663
setupE2E (projectName: string) {
4764
Fixtures.scaffoldProject(projectName)
4865

@@ -53,11 +70,49 @@ export async function e2ePluginSetup (projectRoot: string, on: Cypress.PluginEve
5370
if (obj.activeTestId !== currentTestId) {
5471
await ctx?.destroy()
5572
currentTestId = obj.activeTestId
73+
remoteGraphQLIntercept = undefined
5674
testState = {};
5775
({ serverPortPromise, ctx } = runInternalServer({
5876
projectRoot: null,
5977
}) as {ctx: DataContext, serverPortPromise: Promise<number>})
6078

79+
const fetchApi = ctx.util.fetch
80+
81+
sinon.stub(ctx.util, 'fetch').get(() => {
82+
return async (url: RequestInfo, init?: RequestInit) => {
83+
if (!String(url).endsWith('/test-runner-graphql')) {
84+
return fetchApi(url, init)
85+
}
86+
87+
const { query, variables } = JSON.parse(String(init?.body))
88+
const document = parse(query)
89+
const operationName = getOperationName(document)
90+
91+
let result = await execute({
92+
operationName,
93+
document,
94+
variableValues: variables,
95+
schema: cloudSchema,
96+
rootValue: CloudRunQuery,
97+
contextValue: {
98+
__server__: ctx,
99+
},
100+
})
101+
102+
if (remoteGraphQLIntercept) {
103+
result = remoteGraphQLIntercept({
104+
operationName,
105+
variables,
106+
document,
107+
query,
108+
result,
109+
})
110+
}
111+
112+
return new Response(JSON.stringify(result), { status: 200 })
113+
}
114+
})
115+
61116
await serverPortPromise
62117
}
63118

0 commit comments

Comments
 (0)