mirror of
https://github.com/cypress-io/cypress.git
synced 2026-01-05 14:09:46 -06:00
fix: UNIFY-1625 Runs tab not updating in real time (#21370)
* feat: improved CloudDataSource caching & tests * feat: pushFragment subscription, for updating the client with remote data (#21408) * refactor: add pushFragment utility for pushing remote data into the client * fix: UNIFY-1625 Runs tab not updating in real time (#21412) * allow returning a Response object in the remoteGraphQLIntercept, handle 401
This commit is contained in:
@@ -57,7 +57,7 @@ generates:
|
||||
nonOptionalTypename: true
|
||||
- 'packages/frontend-shared/script/codegen-type-map.js'
|
||||
|
||||
'./packages/frontend-shared/cypress/support/generated/test-cloud-graphql-types.gen.ts':
|
||||
'./packages/graphql/src/gen/test-cloud-graphql-types.gen.ts':
|
||||
schema: 'packages/graphql/schemas/cloud.graphql'
|
||||
plugins:
|
||||
- add:
|
||||
|
||||
@@ -47,12 +47,8 @@ When we hit the remote GraphQL server, we mock against the same mocked schema we
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
// Currently, all remote requests go through here, we want to use this to modify the
|
||||
// remote request before it's used and avoid touching the login query
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
if (proj.runs?.nodes) {
|
||||
proj.runs.nodes = []
|
||||
}
|
||||
}
|
||||
if (obj.result.data?.cloudProjectBySlug.runs?.nodes) {
|
||||
obj.result.data.cloudProjectBySlug.runs.nodes = []
|
||||
}
|
||||
|
||||
return obj.result
|
||||
|
||||
@@ -87,7 +87,6 @@
|
||||
"@graphql-codegen/typescript": "2.4.2",
|
||||
"@graphql-codegen/typescript-operations": "2.2.3",
|
||||
"@graphql-codegen/typescript-urql-graphcache": "2.2.3",
|
||||
"@graphql-tools/batch-delegate": "8.1.0",
|
||||
"@graphql-tools/delegate": "8.2.1",
|
||||
"@graphql-tools/utils": "8.2.3",
|
||||
"@graphql-tools/wrap": "8.1.1",
|
||||
|
||||
@@ -219,18 +219,18 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
// Currently, all remote requests go through here, we want to use this to modify the
|
||||
// remote request before it's used and avoid touching the login query
|
||||
if (obj.result.data?.cloudProjectBySlug && obj.variables._v0_slug === 'abcdef42') {
|
||||
const proj = obj.result.data.cloudProjectBySlug
|
||||
|
||||
if (obj.result.data?.cloudProjectsBySlugs && obj.variables._v0_slugs.includes('abcdef42')) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
proj.__typename = 'CloudProjectNotFound'
|
||||
proj.message = 'Cloud Project Not Found'
|
||||
}
|
||||
proj.__typename = 'CloudProjectNotFound'
|
||||
proj.message = 'Cloud Project Not Found'
|
||||
}
|
||||
|
||||
if (obj.result.data?.cloudViewer?.organizations?.nodes) {
|
||||
const projectNodes = obj.result.data?.cloudViewer.organizations.nodes[0].projects.nodes
|
||||
|
||||
projectNodes.push({
|
||||
__typename: 'CloudProject',
|
||||
id: '1',
|
||||
slug: 'ghijkl',
|
||||
name: 'Mock Project',
|
||||
@@ -270,12 +270,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
|
||||
it('if project Id is specified in config file that is not accessible, shows call to action', () => {
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = false
|
||||
}
|
||||
if (obj.result.data?.cloudProjectBySlug) {
|
||||
const proj = obj.result.data.cloudProjectBySlug
|
||||
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = false
|
||||
}
|
||||
|
||||
return obj.result
|
||||
@@ -299,12 +299,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
return obj.result
|
||||
}
|
||||
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = false
|
||||
}
|
||||
if (obj.result.data?.cloudProjectBySlug) {
|
||||
const proj = obj.result.data.cloudProjectBySlug
|
||||
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = false
|
||||
}
|
||||
|
||||
return obj.result
|
||||
@@ -321,13 +321,13 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
|
||||
it('updates the button text when the request access button is clicked', () => {
|
||||
cy.remoteGraphQLIntercept(async (obj, testState) => {
|
||||
if (obj.operationName === 'Runs_currentProject_cloudProject_batched') {
|
||||
for (const proj of obj!.result!.data!.cloudProjectsBySlugs) {
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = false
|
||||
testState.project = proj
|
||||
}
|
||||
if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') {
|
||||
const proj = obj!.result!.data!.cloudProjectBySlug
|
||||
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = false
|
||||
testState.project = proj
|
||||
} else if (obj.operationName === 'RunsErrorRenderer_RequestAccess_cloudProjectRequestAccess') {
|
||||
obj!.result!.data!.cloudProjectRequestAccess = {
|
||||
...testState.project,
|
||||
@@ -355,12 +355,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
// Currently, all remote requests go through here, we want to use this to modify the
|
||||
// remote request before it's used and avoid touching the login query
|
||||
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = true
|
||||
}
|
||||
if (obj.result.data?.cloudProjectBySlug) {
|
||||
const proj = obj.result.data.cloudProjectBySlug
|
||||
|
||||
proj.__typename = 'CloudProjectUnauthorized'
|
||||
proj.message = 'Cloud Project Unauthorized'
|
||||
proj.hasRequestedAccess = true
|
||||
}
|
||||
|
||||
return obj.result
|
||||
@@ -386,12 +386,8 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
// Currently, all remote requests go through here, we want to use this to modify the
|
||||
// remote request before it's used and avoid touching the login query
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
if (proj.runs?.nodes) {
|
||||
proj.runs.nodes = []
|
||||
}
|
||||
}
|
||||
if (obj.result.data?.cloudProjectBySlug?.runs?.nodes) {
|
||||
obj.result.data.cloudProjectBySlug.runs.nodes = []
|
||||
}
|
||||
|
||||
return obj.result
|
||||
@@ -409,12 +405,8 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
|
||||
cy.loginUser()
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
if (proj.runs?.nodes) {
|
||||
proj.runs.nodes = []
|
||||
}
|
||||
}
|
||||
if (obj.result.data?.cloudProjectBySlug?.runs?.nodes) {
|
||||
obj.result.data.cloudProjectBySlug.runs.nodes = []
|
||||
}
|
||||
|
||||
return obj.result
|
||||
@@ -438,12 +430,8 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
|
||||
cy.loginUser()
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
for (const proj of obj.result.data.cloudProjectsBySlugs) {
|
||||
if (proj.runs?.nodes) {
|
||||
proj.runs.nodes = []
|
||||
}
|
||||
}
|
||||
if (obj.result.data?.cloudProjectBySlug?.runs?.nodes) {
|
||||
obj.result.data.cloudProjectBySlug.runs.nodes = []
|
||||
}
|
||||
|
||||
return obj.result
|
||||
@@ -480,17 +468,17 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
|
||||
cy.get('[href="http://dummy.cypress.io/runs/0"]').first().within(() => {
|
||||
cy.findByText('fix: make gql work CANCELLED')
|
||||
cy.get('[data-cy="run-card-icon"]')
|
||||
cy.get('[data-cy="run-card-icon-CANCELLED"]')
|
||||
})
|
||||
|
||||
cy.get('[href="http://dummy.cypress.io/runs/1"]').first().within(() => {
|
||||
cy.findByText('fix: make gql work ERRORED')
|
||||
cy.get('[data-cy="run-card-icon"]')
|
||||
cy.get('[data-cy="run-card-icon-ERRORED"]')
|
||||
})
|
||||
|
||||
cy.get('[href="http://dummy.cypress.io/runs/2"]').first().within(() => {
|
||||
cy.findByText('fix: make gql work FAILED')
|
||||
cy.get('[data-cy="run-card-icon"]')
|
||||
cy.get('[data-cy="run-card-icon-FAILED"]')
|
||||
})
|
||||
|
||||
cy.get('[href="http://dummy.cypress.io/runs/0"]').first().as('firstRun')
|
||||
@@ -565,4 +553,110 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
cy.get('[data-cy=warning-alert]').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refetching', () => {
|
||||
let obj: {toCall?: Function} = {}
|
||||
const RUNNING_COUNT = 3
|
||||
|
||||
beforeEach(() => {
|
||||
cy.scaffoldProject('component-tests')
|
||||
cy.openProject('component-tests')
|
||||
cy.startAppServer('component')
|
||||
cy.loginUser()
|
||||
cy.remoteGraphQLIntercept((obj, testState) => {
|
||||
if (obj.result.data?.cloudProjectBySlug?.runs?.nodes.length) {
|
||||
obj.result.data.cloudProjectBySlug.runs.nodes.map((run) => {
|
||||
run.status = 'RUNNING'
|
||||
})
|
||||
|
||||
obj.result.data.cloudProjectBySlug.runs.nodes = obj.result.data.cloudProjectBySlug.runs.nodes.slice(0, 3)
|
||||
}
|
||||
|
||||
return obj.result
|
||||
})
|
||||
|
||||
cy.visitApp('/runs', {
|
||||
onBeforeLoad (win) {
|
||||
const setTimeout = win.setTimeout
|
||||
|
||||
// @ts-expect-error
|
||||
win.setTimeout = function (fn, time) {
|
||||
if (fn.name === 'fetchNewerRuns') {
|
||||
obj.toCall = fn
|
||||
} else {
|
||||
setTimeout(fn, time)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should re-query for executing runs', () => {
|
||||
cy.get('[data-cy="run-card-icon-RUNNING"]').should('have.length', RUNNING_COUNT).should('be.visible')
|
||||
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
if (obj.result.data?.cloudNode?.newerRuns?.nodes) {
|
||||
obj.result.data.cloudNode.newerRuns.nodes = []
|
||||
}
|
||||
|
||||
if (obj.result.data?.cloudNodesByIds) {
|
||||
obj.result.data?.cloudNodesByIds.map((node) => {
|
||||
node.status = 'RUNNING'
|
||||
})
|
||||
|
||||
obj.result.data.cloudNodesByIds[0].status = 'PASSED'
|
||||
}
|
||||
|
||||
return obj.result
|
||||
})
|
||||
|
||||
function completeNext (passed) {
|
||||
cy.wrap(obj).invoke('toCall').then(() => {
|
||||
cy.get('[data-cy="run-card-icon-PASSED"]').should('have.length', passed).should('be.visible')
|
||||
if (passed < RUNNING_COUNT) {
|
||||
completeNext(passed + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
completeNext(1)
|
||||
})
|
||||
|
||||
it('should fetch newer runs and maintain them when navigating', () => {
|
||||
cy.get('[data-cy="run-card-icon-RUNNING"]').should('have.length', RUNNING_COUNT).should('be.visible')
|
||||
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
if (obj.result.data?.cloudNodesByIds) {
|
||||
obj.result.data?.cloudNodesByIds.map((node) => {
|
||||
node.status = 'PASSED'
|
||||
node.totalPassed = 100
|
||||
})
|
||||
}
|
||||
|
||||
return obj.result
|
||||
})
|
||||
|
||||
cy.get('[data-cy="run-card-icon-RUNNING"]').should('have.length', 3).should('be.visible')
|
||||
cy.wrap(obj).invoke('toCall')
|
||||
|
||||
cy.get('[data-cy="run-card-icon-PASSED"]').should('have.length', 3).should('be.visible').within(() => {
|
||||
cy.get('[data-cy="runResults-passed-count"]').should('contain', 100)
|
||||
})
|
||||
|
||||
cy.get('[data-cy="run-card-icon-RUNNING"]').should('have.length', 2).should('be.visible')
|
||||
|
||||
// If we navigate away & back, we should see the same runs
|
||||
cy.get('[href="#/settings"]').click()
|
||||
cy.remoteGraphQLIntercept((obj) => obj.result)
|
||||
|
||||
cy.get('[href="#/runs"]').click()
|
||||
|
||||
cy.get('[data-cy="run-card-icon-PASSED"]').should('have.length', 3).should('be.visible')
|
||||
cy.get('[data-cy="run-card-icon-RUNNING"]').should('have.length', 2).should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
cy.loginUser()
|
||||
cy.visitApp()
|
||||
|
||||
// Simulate no orgs
|
||||
cy.remoteGraphQLIntercept(async (obj) => {
|
||||
if ((obj.operationName === 'CheckCloudOrganizations_cloudViewerChange_cloudViewer' || obj.operationName === 'Runs_cloudViewer') && obj.callCount < 2) {
|
||||
if ((obj.operationName === 'CheckCloudOrganizations_cloudViewerChange_cloudViewer' || obj.operationName === 'Runs_cloudViewer')) {
|
||||
if (obj.result.data?.cloudViewer?.organizations?.nodes) {
|
||||
obj.result.data.cloudViewer.organizations.nodes = []
|
||||
}
|
||||
@@ -41,6 +42,11 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
|
||||
cy.contains('button', defaultMessages.runs.connect.modal.createOrg.waitingButton).should('be.visible')
|
||||
cy.contains('a', defaultMessages.links.needHelp).should('have.attr', 'href', 'https://on.cypress.io/adding-new-project')
|
||||
|
||||
// Clear the current intercept to simulate a response with orgs
|
||||
cy.remoteGraphQLIntercept((obj) => {
|
||||
return obj.result
|
||||
})
|
||||
|
||||
cy.withCtx(async (ctx) => {
|
||||
await ctx.util.fetch(`http://127.0.0.1:${ctx.gqlServerPort}/cloud-notification?operationName=orgCreated`)
|
||||
})
|
||||
|
||||
@@ -366,8 +366,8 @@ describe('App Top Nav Workflows', () => {
|
||||
cy.startAppServer('component')
|
||||
|
||||
cy.remoteGraphQLIntercept((obj) => {
|
||||
if (obj.result.data?.cloudProjectsBySlugs) {
|
||||
throw new Error('Unauthorized')
|
||||
if (obj.result.data?.cloudProjectBySlug) {
|
||||
return new obj.Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
return obj.result
|
||||
|
||||
@@ -5,18 +5,20 @@
|
||||
<RunsContainer
|
||||
v-else
|
||||
:gql="query.data.value"
|
||||
@reexecute-runs-query="reexecuteRunsQuery"
|
||||
:online="isOnlineRef"
|
||||
/>
|
||||
</TransitionQuickFade>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { gql, useQuery } from '@urql/vue'
|
||||
import { RunsDocument } from '../generated/graphql'
|
||||
import RunsSkeleton from '../runs/RunsSkeleton.vue'
|
||||
import RunsContainer from '../runs/RunsContainer.vue'
|
||||
import TransitionQuickFade from '@cy/components/transitions/TransitionQuickFade.vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
|
||||
gql`
|
||||
query Runs {
|
||||
@@ -25,7 +27,19 @@ query Runs {
|
||||
|
||||
const query = useQuery({ query: RunsDocument, requestPolicy: 'network-only' })
|
||||
|
||||
function reexecuteRunsQuery () {
|
||||
query.executeQuery()
|
||||
}
|
||||
const isOnlineRef = ref(true)
|
||||
const online = useOnline()
|
||||
|
||||
watchEffect(() => {
|
||||
// We want to keep track of the previous state to refetch the query
|
||||
// when the internet connection is back
|
||||
if (!online.value && isOnlineRef.value) {
|
||||
isOnlineRef.value = false
|
||||
}
|
||||
|
||||
if (online.value && !isOnlineRef.value) {
|
||||
isOnlineRef.value = true
|
||||
query.executeQuery()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import CloudConnectButton from './CloudConnectButton.vue'
|
||||
import { CloudConnectButtonFragmentDoc } from '../generated/graphql-test'
|
||||
import { CloudUserStubs } from '@packages/frontend-shared/cypress/support/mock-graphql/stubgql-CloudTypes'
|
||||
import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
|
||||
|
||||
describe('<CloudConnectButton />', () => {
|
||||
it('show user connect if not connected', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CloudRunStubs } from '@packages/frontend-shared/cypress/support/mock-graphql/stubgql-CloudTypes'
|
||||
import { CloudRunStubs } from '@packages/graphql/test/stubCloudTypes'
|
||||
import { RunCardFragmentDoc } from '../generated/graphql-test'
|
||||
import RunCard from './RunCard.vue'
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<ListRowHeader
|
||||
:icon="icon"
|
||||
data-cy="run-card-icon"
|
||||
:data-cy="`run-card-icon-${run.status}`"
|
||||
>
|
||||
<template #header>
|
||||
{{ run.commitInfo?.summary }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
import { CloudRunStubs } from '@packages/frontend-shared/cypress/support/mock-graphql/stubgql-CloudTypes'
|
||||
import { CloudRunStubs } from '@packages/graphql/test/stubCloudTypes'
|
||||
import { RunCardFragmentDoc } from '../generated/graphql-test'
|
||||
import RunResults from './RunResults.vue'
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:key="i"
|
||||
class="flex px-2 items-center hover:bg-indigo-50"
|
||||
:title="result.name"
|
||||
:data-cy="`runResults-${result.name}-count`"
|
||||
>
|
||||
<component
|
||||
:is="result.icon"
|
||||
@@ -18,6 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { RunResultsFragment } from '../generated/graphql'
|
||||
import { gql } from '@urql/core'
|
||||
import SkippedIcon from '~icons/cy/status-skipped_x12.svg'
|
||||
@@ -41,30 +43,32 @@ const props = defineProps<{
|
||||
gql: RunResultsFragment
|
||||
}>()
|
||||
|
||||
const results = [
|
||||
{
|
||||
value: props.gql.totalSkipped,
|
||||
class: 'icon-dark-gray-400',
|
||||
icon: SkippedIcon,
|
||||
name: t('runs.results.skipped'),
|
||||
},
|
||||
{
|
||||
value: props.gql.totalPending,
|
||||
class: 'icon-dark-gray-400 icon-light-white',
|
||||
icon: PendingIcon,
|
||||
name: t('runs.results.pending'),
|
||||
},
|
||||
{
|
||||
value: props.gql.totalPassed,
|
||||
class: 'icon-dark-jade-400',
|
||||
icon: PassedIcon,
|
||||
name: t('runs.results.passed'),
|
||||
},
|
||||
{
|
||||
value: props.gql.totalFailed,
|
||||
class: 'icon-dark-red-400',
|
||||
icon: FailedIcon,
|
||||
name: t('runs.results.failed'),
|
||||
},
|
||||
]
|
||||
const results = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: props.gql.totalSkipped,
|
||||
class: 'icon-dark-gray-400',
|
||||
icon: SkippedIcon,
|
||||
name: t('runs.results.skipped'),
|
||||
},
|
||||
{
|
||||
value: props.gql.totalPending,
|
||||
class: 'icon-dark-gray-400 icon-light-white',
|
||||
icon: PendingIcon,
|
||||
name: t('runs.results.pending'),
|
||||
},
|
||||
{
|
||||
value: props.gql.totalPassed,
|
||||
class: 'icon-dark-jade-400',
|
||||
icon: PassedIcon,
|
||||
name: t('runs.results.passed'),
|
||||
},
|
||||
{
|
||||
value: props.gql.totalFailed,
|
||||
class: 'icon-dark-red-400',
|
||||
icon: FailedIcon,
|
||||
name: t('runs.results.failed'),
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import RunsContainer from './RunsContainer.vue'
|
||||
import { RunsContainerFragmentDoc } from '../generated/graphql-test'
|
||||
import { CloudUserStubs } from '@packages/frontend-shared/cypress/support/mock-graphql/stubgql-CloudTypes'
|
||||
import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
|
||||
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('<RunsContainer />', { keystrokeDelay: 0 }, () => {
|
||||
result.cloudViewer = cloudViewer
|
||||
},
|
||||
render (gqlVal) {
|
||||
return <RunsContainer gql={gqlVal} />
|
||||
return <RunsContainer gql={gqlVal} online />
|
||||
},
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('<RunsContainer />', { keystrokeDelay: 0 }, () => {
|
||||
}
|
||||
},
|
||||
render (gqlVal) {
|
||||
return <RunsContainer gql={gqlVal} />
|
||||
return <RunsContainer gql={gqlVal} online />
|
||||
},
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('<RunsContainer />', { keystrokeDelay: 0 }, () => {
|
||||
it('renders instructions and login button', () => {
|
||||
cy.mountFragment(RunsContainerFragmentDoc, {
|
||||
render (gqlVal) {
|
||||
return <RunsContainer gql={gqlVal} />
|
||||
return <RunsContainer gql={gqlVal} online />
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -44,26 +44,31 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { gql } from '@urql/vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { gql, useMutation } from '@urql/vue'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import NoInternetConnection from '@packages/frontend-shared/src/components/NoInternetConnection.vue'
|
||||
import RunCard from './RunCard.vue'
|
||||
import RunsConnect from './RunsConnect.vue'
|
||||
import RunsConnectSuccessAlert from './RunsConnectSuccessAlert.vue'
|
||||
import RunsEmpty from './RunsEmpty.vue'
|
||||
import type { RunsContainerFragment } from '../generated/graphql'
|
||||
import { RunsContainerFragment, RunsContainer_FetchNewerRunsDocument } from '../generated/graphql'
|
||||
import Warning from '@packages/frontend-shared/src/warning/Warning.vue'
|
||||
import RunsErrorRenderer from './RunsErrorRenderer.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const online = useOnline()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reexecuteRunsQuery'): void
|
||||
}>()
|
||||
gql`
|
||||
fragment RunsContainer_RunsConnection on CloudRunConnection {
|
||||
nodes {
|
||||
id
|
||||
...RunCard
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
gql`
|
||||
fragment RunsContainer on Query {
|
||||
@@ -78,10 +83,7 @@ fragment RunsContainer on Query {
|
||||
... on CloudProject {
|
||||
id
|
||||
runs(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
...RunCard
|
||||
}
|
||||
...RunsContainer_RunsConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,30 +94,100 @@ fragment RunsContainer on Query {
|
||||
...RunsConnect
|
||||
}`
|
||||
|
||||
gql`
|
||||
mutation RunsContainer_FetchNewerRuns(
|
||||
$cloudProjectNodeId: ID!,
|
||||
$beforeCursor: String,
|
||||
$hasBeforeCursor: Boolean!,
|
||||
$refreshPendingRuns: [ID!]!,
|
||||
$hasRefreshPendingRuns: Boolean!
|
||||
) {
|
||||
refetchRemote {
|
||||
cloudNode(id: $cloudProjectNodeId) {
|
||||
id
|
||||
__typename
|
||||
... on CloudProject {
|
||||
runs(first: 10) @skip(if: $hasBeforeCursor) {
|
||||
...RunsContainer_RunsConnection
|
||||
}
|
||||
newerRuns: runs(last: 10, before: $beforeCursor) @include(if: $hasBeforeCursor) {
|
||||
...RunsContainer_RunsConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
cloudNodesByIds(ids: $refreshPendingRuns) @include(if: $hasRefreshPendingRuns) {
|
||||
id
|
||||
... on CloudRun {
|
||||
...RunCard
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const currentProject = computed(() => props.gql.currentProject)
|
||||
const cloudViewer = computed(() => props.gql.cloudViewer)
|
||||
|
||||
const variables = computed(() => {
|
||||
if (currentProject.value?.cloudProject?.__typename === 'CloudProject') {
|
||||
const toRefresh = currentProject.value?.cloudProject.runs?.nodes?.map((r) => r.status === 'RUNNING' ? r.id : null).filter((f) => f) ?? []
|
||||
|
||||
return {
|
||||
cloudProjectNodeId: currentProject.value?.cloudProject.id,
|
||||
beforeCursor: currentProject.value?.cloudProject.runs?.pageInfo.startCursor,
|
||||
hasBeforeCursor: Boolean(currentProject.value?.cloudProject.runs?.pageInfo.startCursor),
|
||||
refreshPendingRuns: toRefresh,
|
||||
hasRefreshPendingRuns: toRefresh.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined as any
|
||||
})
|
||||
|
||||
const refetcher = useMutation(RunsContainer_FetchNewerRunsDocument)
|
||||
|
||||
// 15 seconds polling
|
||||
const POLL_FOR_LATEST = 1000 * 15
|
||||
let timeout: null | number = null
|
||||
|
||||
function startPolling () {
|
||||
timeout = window.setTimeout(function fetchNewerRuns () {
|
||||
if (variables.value && props.online) {
|
||||
refetcher.executeMutation(variables.value)
|
||||
.then(() => {
|
||||
startPolling()
|
||||
})
|
||||
} else {
|
||||
startPolling()
|
||||
}
|
||||
}, POLL_FOR_LATEST)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Always fetch when the component mounts, and we're not already fetching
|
||||
if (props.online && !refetcher.fetching) {
|
||||
refetcher.executeMutation(variables.value)
|
||||
}
|
||||
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
timeout = null
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
gql: RunsContainerFragment
|
||||
online: boolean
|
||||
}>()
|
||||
|
||||
const isCloudProjectReturned = computed(() => props.gql.currentProject?.cloudProject?.__typename === 'CloudProject')
|
||||
|
||||
const isOnlineRef = ref(true)
|
||||
const showConnectSuccessAlert = ref(false)
|
||||
|
||||
watchEffect(() => {
|
||||
// We want to keep track of the previous state to refetch the query
|
||||
// when the internet connection is back
|
||||
if (!online.value && isOnlineRef.value) {
|
||||
isOnlineRef.value = false
|
||||
}
|
||||
|
||||
if (online.value && !isOnlineRef.value) {
|
||||
isOnlineRef.value = true
|
||||
emit('reexecuteRunsQuery')
|
||||
}
|
||||
})
|
||||
|
||||
const currentProject = computed(() => props.gql.currentProject)
|
||||
const cloudViewer = computed(() => props.gql.cloudViewer)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
import { CloudUserStubs,
|
||||
CloudOrganizationConnectionStubs,
|
||||
} from '@packages/frontend-shared/cypress/support/mock-graphql/stubgql-CloudTypes'
|
||||
} from '@packages/graphql/test/stubCloudTypes'
|
||||
import { CloudConnectModalsFragmentDoc } from '../../generated/graphql-test'
|
||||
import CloudConnectModals from './CloudConnectModals.vue'
|
||||
|
||||
|
||||
@@ -54,12 +54,11 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { gql, useQuery } from '@urql/vue'
|
||||
import { gql, useMutation } from '@urql/vue'
|
||||
import StandardModal from '@cy/components/StandardModal.vue'
|
||||
import Button from '@cy/components/Button.vue'
|
||||
import ExternalLink from '@cy/gql-components/ExternalLink.vue'
|
||||
import type { CreateCloudOrgModalFragment } from '../../generated/graphql'
|
||||
import { CloudOrganizationsCheckDocument } from '../../generated/graphql'
|
||||
import { CreateCloudOrgModalFragment, CreateCloudOrgModal_CloudOrganizationsCheckDocument } from '../../generated/graphql'
|
||||
import { useI18n } from '@cy/i18n'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
@@ -77,8 +76,10 @@ fragment CreateCloudOrgModal on CloudUser {
|
||||
`
|
||||
|
||||
gql`
|
||||
query CloudOrganizationsCheck {
|
||||
...CloudConnectModals
|
||||
mutation CreateCloudOrgModal_CloudOrganizationsCheck {
|
||||
refreshOrganizations {
|
||||
...CloudConnectModals
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -86,13 +87,9 @@ const props = defineProps<{
|
||||
gql: CreateCloudOrgModalFragment
|
||||
}>()
|
||||
|
||||
const query = useQuery({
|
||||
query: CloudOrganizationsCheckDocument,
|
||||
requestPolicy: 'network-only',
|
||||
pause: true,
|
||||
})
|
||||
const refreshOrgs = useMutation(CreateCloudOrgModal_CloudOrganizationsCheckDocument)
|
||||
|
||||
const refetch = useDebounceFn(() => query.executeQuery(), 1000)
|
||||
const refetch = useDebounceFn(() => refreshOrgs.executeMutation({}), 1000)
|
||||
|
||||
const waitingOrgToBeCreated = ref(false)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultMessages } from '@cy/i18n'
|
||||
import { CloudOrganizationConnectionStubs, CloudUserStubs } from '@packages/frontend-shared/cypress/support/mock-graphql/stubgql-CloudTypes'
|
||||
import { CloudOrganizationConnectionStubs, CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
|
||||
import { SelectCloudProjectModalFragmentDoc } from '../../generated/graphql-test'
|
||||
import SelectCloudProjectModal from '../modals/SelectCloudProjectModal.vue'
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@babel/code-frame": "7.8.3",
|
||||
"@babel/generator": "7.17.9",
|
||||
"@babel/parser": "7.13.0",
|
||||
"@graphql-tools/batch-execute": "^8.4.6",
|
||||
"@urql/core": "2.4.4",
|
||||
"@urql/exchange-execute": "1.1.0",
|
||||
"@urql/exchange-graphcache": "4.3.6",
|
||||
@@ -83,4 +84,4 @@
|
||||
"src"
|
||||
],
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,9 @@ import { VersionsDataSource } from './sources/VersionsDataSource'
|
||||
import type { SocketIONamespace, SocketIOServer } from '@packages/socket'
|
||||
import { globalPubSub } from '.'
|
||||
import { InjectedConfigApi, ProjectLifecycleManager } from './data/ProjectLifecycleManager'
|
||||
import type { CypressError } from '@packages/errors'
|
||||
import { CypressError, getError } from '@packages/errors'
|
||||
import { ErrorDataSource } from './sources/ErrorDataSource'
|
||||
import { GraphQLDataSource } from './sources/GraphQLDataSource'
|
||||
|
||||
const IS_DEV_ENV = process.env.CYPRESS_INTERNAL_ENV !== 'production'
|
||||
|
||||
@@ -51,6 +52,7 @@ export interface InternalDataContextOptions {
|
||||
|
||||
export interface DataContextConfig {
|
||||
schema: GraphQLSchema
|
||||
schemaCloud: GraphQLSchema
|
||||
mode: 'run' | 'open'
|
||||
modeOptions: Partial<AllModeOptions>
|
||||
electronApp?: ElectronApp
|
||||
@@ -92,10 +94,23 @@ export class DataContext {
|
||||
this.lifecycleManager = new ProjectLifecycleManager(this)
|
||||
}
|
||||
|
||||
get schema () {
|
||||
return this._config.schema
|
||||
}
|
||||
|
||||
get schemaCloud () {
|
||||
return this._config.schemaCloud
|
||||
}
|
||||
|
||||
get isRunMode () {
|
||||
return this._config.mode === 'run'
|
||||
}
|
||||
|
||||
@cached
|
||||
get graphql () {
|
||||
return new GraphQLDataSource()
|
||||
}
|
||||
|
||||
get electronApp () {
|
||||
return this._config.electronApp
|
||||
}
|
||||
@@ -184,7 +199,20 @@ export class DataContext {
|
||||
|
||||
@cached
|
||||
get cloud () {
|
||||
return new CloudDataSource(this)
|
||||
return new CloudDataSource({
|
||||
fetch: (...args) => this.util.fetch(...args),
|
||||
getUser: () => this.user,
|
||||
logout: () => this.actions.auth.logout().catch(this.logTraceError),
|
||||
onError: (err) => {
|
||||
// This should never happen in prod, and if it does, it means we've intentionally broken the
|
||||
// remote contract with the test runner. Showing the main overlay is too heavy-handed of an action
|
||||
// to take here, so we only show it in development, when we maybe did something wrong in our e2e
|
||||
// Cypress test mocking and want to know immediately in the UI that things are broken
|
||||
if (process.env.CYPRESS_INTERNAL_ENV !== 'production') {
|
||||
return this.onError(getError('DASHBOARD_GRAPHQL_ERROR', err), 'Cypress Dashboard Error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@cached
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parse } from 'graphql'
|
||||
import type { DataContext } from '..'
|
||||
import type { AuthenticatedUserShape, AuthStateShape } from '../data'
|
||||
|
||||
@@ -34,9 +35,8 @@ export class AuthActions {
|
||||
async checkAuth () {
|
||||
const result = await this.ctx.cloud.executeRemoteGraphQL({
|
||||
operationType: 'query',
|
||||
query: `query Cypress_CheckAuth { cloudViewer { id } }`,
|
||||
document: parse(`query Cypress_CheckAuth { cloudViewer { id email fullName } }`),
|
||||
variables: {},
|
||||
requestPolicy: 'network-only',
|
||||
})
|
||||
|
||||
if (!result.data?.cloudViewer) {
|
||||
|
||||
@@ -3,6 +3,12 @@ import { EventEmitter } from 'stream'
|
||||
|
||||
import type { DataContext } from '../DataContext'
|
||||
|
||||
export interface PushFragmentData {
|
||||
data: any
|
||||
target: string
|
||||
fragment: string
|
||||
}
|
||||
|
||||
abstract class DataEmitterEvents {
|
||||
protected pub = new EventEmitter()
|
||||
|
||||
@@ -74,6 +80,14 @@ abstract class DataEmitterEvents {
|
||||
this._emit('specsChange')
|
||||
}
|
||||
|
||||
/**
|
||||
* When we want to update the cache with known values from the server, without
|
||||
* triggering a full refresh, we can send down a specific fragment / data to update
|
||||
*/
|
||||
pushFragment (toPush: PushFragmentData[]) {
|
||||
this._emit('pushFragment', toPush)
|
||||
}
|
||||
|
||||
private _emit <Evt extends keyof DataEmitterEvents> (evt: Evt, ...args: Parameters<DataEmitterEvents[Evt]>) {
|
||||
this.pub.emit(evt, ...args)
|
||||
}
|
||||
@@ -125,7 +139,8 @@ export class DataEmitterActions extends DataEmitterEvents {
|
||||
* when subscribing, we want to execute the operation to get the up-to-date initial
|
||||
* value, and then we keep a deferred object, resolved when the given emitter is fired
|
||||
*/
|
||||
subscribeTo (evt: keyof DataEmitterEvents, sendInitial = true): AsyncGenerator<any> {
|
||||
subscribeTo (evt: keyof DataEmitterEvents, opts?: {sendInitial: boolean}): AsyncGenerator<any> {
|
||||
const { sendInitial = true } = opts ?? {}
|
||||
let hasSentInitial = false
|
||||
let dfd: pDefer.DeferredPromise<any> | undefined
|
||||
let pending: any[] = []
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { DataContext } from './DataContext'
|
||||
|
||||
export { DocumentNodeBuilder } from './util/DocumentNodeBuilder'
|
||||
|
||||
export {
|
||||
DataContext,
|
||||
} from './DataContext'
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
// @ts-ignore
|
||||
import pkg from '@packages/root'
|
||||
import debugLib from 'debug'
|
||||
import { cacheExchange, Cache } from '@urql/exchange-graphcache'
|
||||
import fetch, { Response } from 'cross-fetch'
|
||||
|
||||
import type { DataContext } from '..'
|
||||
import pDefer from 'p-defer'
|
||||
import getenv from 'getenv'
|
||||
import { pipe, subscribe, toPromise, take } from 'wonka'
|
||||
import type { DocumentNode, OperationTypeNode } from 'graphql'
|
||||
import { DocumentNode, ExecutionResult, GraphQLResolveInfo, OperationTypeNode, print } from 'graphql'
|
||||
import {
|
||||
createClient,
|
||||
cacheExchange,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
errorExchange,
|
||||
Client,
|
||||
createRequest,
|
||||
OperationResult,
|
||||
stringifyVariables,
|
||||
RequestPolicy,
|
||||
} from '@urql/core'
|
||||
import _ from 'lodash'
|
||||
import { getError } from '@packages/errors'
|
||||
import type { RemoteExecutionRoot } from '@packages/graphql'
|
||||
import type { core } from 'nexus'
|
||||
import { delegateToSchema } from '@graphql-tools/delegate'
|
||||
import { urqlCacheKeys } from '../util/urqlCacheKeys'
|
||||
import { urqlSchema } from '../gen/urql-introspection.gen'
|
||||
import type { AuthenticatedUserShape } from '../data'
|
||||
import { pathToArray } from 'graphql/jsutils/Path'
|
||||
|
||||
export type CloudDataResponse = ExecutionResult & Partial<OperationResult> & { executing?: Promise<ExecutionResult & Partial<OperationResult>> }
|
||||
|
||||
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,117 +36,238 @@ const REMOTE_SCHEMA_URLS = {
|
||||
production: 'https://dashboard.cypress.io',
|
||||
}
|
||||
|
||||
export interface CloudExecuteRemote extends RemoteExecutionRoot {
|
||||
operationType: OperationTypeNode
|
||||
query: string
|
||||
document?: DocumentNode
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type StartsWith<T, Prefix extends string> = T extends `${Prefix}${infer _U}` ? T : never
|
||||
type CloudQueryField = StartsWith<keyof NexusGen['fieldTypes']['Query'], 'cloud'>
|
||||
|
||||
export interface CloudExecuteQuery {
|
||||
document: DocumentNode
|
||||
variables: any
|
||||
}
|
||||
|
||||
export class CloudDataSource {
|
||||
private _cloudUrqlClient: Client
|
||||
export interface CloudExecuteRemote extends CloudExecuteQuery {
|
||||
operationType: OperationTypeNode
|
||||
requestPolicy?: RequestPolicy
|
||||
onUpdatedResult?: (data: any) => any
|
||||
}
|
||||
|
||||
constructor (private ctx: DataContext) {
|
||||
this._cloudUrqlClient = this.reset()
|
||||
export interface CloudExecuteDelegateFieldParams<F extends CloudQueryField> {
|
||||
field: F
|
||||
args: core.ArgsValue<'Query', F>
|
||||
ctx: DataContext
|
||||
info: GraphQLResolveInfo
|
||||
}
|
||||
|
||||
export interface CloudDataSourceParams {
|
||||
fetch: typeof fetch
|
||||
getUser(): AuthenticatedUserShape | null
|
||||
logout(): void
|
||||
onError(e: Error): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The CloudDataSource manages the interaction with the remote GraphQL server
|
||||
* It maintains a normalized cache of all data we have seen from the cloud and
|
||||
* ensures the data is kept up-to-date as it changes
|
||||
*/
|
||||
export class CloudDataSource {
|
||||
#cloudUrqlClient: Client
|
||||
|
||||
constructor (private params: CloudDataSourceParams) {
|
||||
this.#cloudUrqlClient = this.reset()
|
||||
}
|
||||
|
||||
get #user () {
|
||||
return this.params.getUser()
|
||||
}
|
||||
|
||||
get #additionalHeaders () {
|
||||
return {
|
||||
'Authorization': this.#user ? `bearer ${this.#user.authToken}` : '',
|
||||
'x-cypress-version': pkg.version,
|
||||
}
|
||||
}
|
||||
|
||||
reset () {
|
||||
return this._cloudUrqlClient = createClient({
|
||||
return this.#cloudUrqlClient = createClient({
|
||||
url: `${REMOTE_SCHEMA_URLS[cloudEnv]}/test-runner-graphql`,
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
cacheExchange({
|
||||
// @ts-ignore
|
||||
schema: urqlSchema,
|
||||
...urqlCacheKeys,
|
||||
resolvers: {},
|
||||
updates: {
|
||||
Mutation: {
|
||||
_cloudCacheInvalidate: (parent, { args }: {args: Parameters<Cache['invalidate']>}, cache, info) => {
|
||||
cache.invalidate(...args)
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
errorExchange({
|
||||
onError: (err, operation) => {
|
||||
// If we receive a 401 from the dashboard, we need to logout the user
|
||||
if (err.response?.status === 401) {
|
||||
this.params.logout()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (err.networkError) {
|
||||
// TODO: UNIFY-1691 handle the networkError via a GraphQL & UI representation
|
||||
// this.params.onError(err.networkError)
|
||||
return
|
||||
}
|
||||
|
||||
if (err.graphQLErrors[0]) {
|
||||
this.params.onError(err.graphQLErrors[0])
|
||||
}
|
||||
},
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
// Set this way so we can intercept the fetch on the context for testing
|
||||
fetch: (...args) => {
|
||||
return this.ctx.util.fetch(...args)
|
||||
fetch: async (uri, init) => {
|
||||
const internalResponse = _.get(init, 'headers.INTERNAL_REQUEST')
|
||||
|
||||
if (internalResponse) {
|
||||
return Promise.resolve(new Response(internalResponse, { status: 200 }))
|
||||
}
|
||||
|
||||
return this.params.fetch(uri, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
...this.#additionalHeaders,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
isLoadingRemote (config: CloudExecuteRemote) {
|
||||
return Boolean(this.#pendingPromises.get(this.#hashRemoteRequest(config)))
|
||||
}
|
||||
|
||||
delegateCloudField <F extends CloudQueryField> (params: CloudExecuteDelegateFieldParams<F>) {
|
||||
return delegateToSchema({
|
||||
operation: 'query',
|
||||
schema: params.ctx.schemaCloud,
|
||||
fieldName: params.field,
|
||||
fieldNodes: params.info.fieldNodes,
|
||||
info: params.info,
|
||||
args: params.args,
|
||||
context: params.ctx,
|
||||
operationName: this.makeOperationName(params.info),
|
||||
})
|
||||
}
|
||||
|
||||
makeOperationName (info: GraphQLResolveInfo) {
|
||||
return `${info.operation.name?.value ?? 'Anonymous'}_${pathToArray(info.path).map((p) => typeof p === 'number' ? 'idx' : p).join('_')}`
|
||||
}
|
||||
|
||||
#pendingPromises = new Map<string, Promise<OperationResult>>()
|
||||
|
||||
#hashRemoteRequest (config: CloudExecuteQuery) {
|
||||
return `${print(config.document)}-${stringifyVariables(config.variables)}`
|
||||
}
|
||||
|
||||
#maybeQueueDeferredExecute (config: CloudExecuteRemote, initialResult?: OperationResult) {
|
||||
const stableKey = this.#hashRemoteRequest(config)
|
||||
|
||||
let loading = this.#pendingPromises.get(stableKey)
|
||||
|
||||
if (loading) {
|
||||
return loading
|
||||
}
|
||||
|
||||
loading = this.#cloudUrqlClient.query(config.document, config.variables, { requestPolicy: 'network-only' }).toPromise().then((op) => {
|
||||
this.#pendingPromises.delete(stableKey)
|
||||
|
||||
if (initialResult && !_.isEqual(op.data, initialResult.data)) {
|
||||
debug('Different Query Value %j, %j', op.data, initialResult.data)
|
||||
|
||||
if (typeof config.onUpdatedResult === 'function') {
|
||||
config.onUpdatedResult(op.data)
|
||||
}
|
||||
|
||||
return op
|
||||
}
|
||||
|
||||
return op
|
||||
})
|
||||
|
||||
this.#pendingPromises.set(stableKey, loading)
|
||||
|
||||
return loading
|
||||
}
|
||||
|
||||
isResolving (config: CloudExecuteQuery) {
|
||||
const stableKey = this.#hashRemoteRequest(config)
|
||||
|
||||
return Boolean(this.#pendingPromises.get(stableKey))
|
||||
}
|
||||
|
||||
hasResolved (config: CloudExecuteQuery) {
|
||||
const eagerResult = this.#cloudUrqlClient.readQuery(config.document, config.variables)
|
||||
|
||||
return Boolean(eagerResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the schema against a remote query. Keeps an urql client for the normalized caching,
|
||||
* Executes the query against a remote schema. Keeps an urql client for the normalized caching,
|
||||
* so we can respond quickly on first-load if we have data. Since this is ultimately being used
|
||||
* as a remote request mechanism for a stitched schema, we reject the promise if we see any errors.
|
||||
*/
|
||||
async executeRemoteGraphQL (config: CloudExecuteRemote): Promise<Partial<OperationResult>> {
|
||||
// TODO(tim): remove this when we start doing remote requests to public APIs
|
||||
if (!this.ctx.user) {
|
||||
executeRemoteGraphQL (config: CloudExecuteRemote): Promise<CloudDataResponse> | CloudDataResponse {
|
||||
// We do not want unauthenticated requests to hit the remote schema
|
||||
if (!this.#user) {
|
||||
return { data: null }
|
||||
}
|
||||
|
||||
const requestPolicy = config.requestPolicy ?? 'cache-and-network'
|
||||
|
||||
const isQuery = config.operationType !== 'mutation'
|
||||
|
||||
const executeCall = isQuery ? 'executeQuery' : 'executeMutation'
|
||||
|
||||
const executingQuery = this._cloudUrqlClient[executeCall](createRequest(config.query, config.variables), {
|
||||
fetch: this.ctx.util.fetch,
|
||||
requestPolicy,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `bearer ${this.ctx.user.authToken}`,
|
||||
'x-cypress-version': pkg.version,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (requestPolicy === 'cache-and-network' && isQuery) {
|
||||
let resolvedData: OperationResult | undefined = undefined
|
||||
const dfd = pDefer<OperationResult>()
|
||||
const pipeline = pipe(
|
||||
executingQuery,
|
||||
subscribe((res) => {
|
||||
debug('executeRemoteGraphQL subscribe res %o', res)
|
||||
|
||||
if (!resolvedData) {
|
||||
resolvedData = res
|
||||
|
||||
// Ignore the error when there's no internet connection or when the
|
||||
// cloud session is not valid, we want to logout the user on the app
|
||||
if (res.error?.networkError && res.error?.networkError.message !== 'Unauthorized') {
|
||||
dfd.resolve({ ...res, error: undefined, data: null })
|
||||
} else if (res.error) {
|
||||
dfd.reject(res.error)
|
||||
} else {
|
||||
dfd.resolve(res)
|
||||
}
|
||||
} else if ((!_.isEqual(resolvedData.data, res.data) || !_.isEqual(resolvedData.error, res.error)) && !res.error?.networkError) {
|
||||
if (res.error) {
|
||||
this.ctx.coreData.dashboardGraphQLError = {
|
||||
cypressError: getError('DASHBOARD_GRAPHQL_ERROR', res.error),
|
||||
}
|
||||
} else {
|
||||
this.ctx.coreData.dashboardGraphQLError = null
|
||||
}
|
||||
|
||||
this.ctx.emitter.toApp()
|
||||
this.ctx.emitter.toLaunchpad()
|
||||
}
|
||||
|
||||
if (!res.stale) {
|
||||
pipeline.unsubscribe()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return dfd.promise
|
||||
if (config.operationType === 'mutation') {
|
||||
return this.#cloudUrqlClient.mutation(config.document, config.variables).toPromise()
|
||||
}
|
||||
|
||||
// take(1) completes the stream immediately after the first value was emitted
|
||||
// avoiding it to hang forever on query operations
|
||||
// https://github.com/FormidableLabs/urql/issues/298
|
||||
return pipe(executingQuery, take(1), toPromise).then((data) => {
|
||||
debug('executeRemoteGraphQL toPromise res %o', data)
|
||||
// First, we check the cache to see if we have the data to fulfill this query
|
||||
const eagerResult = this.#cloudUrqlClient.readQuery(config.document, config.variables)
|
||||
|
||||
if (data.error) {
|
||||
throw data.error
|
||||
// If we do have a synchronous result, return it, and determine if we want to check for
|
||||
// updates to this field
|
||||
if (eagerResult && config.requestPolicy !== 'network-only') {
|
||||
debug(`eagerResult found stale? %s, %o`, eagerResult.stale, eagerResult.data)
|
||||
|
||||
// If we have some of the fields, but not the full thing, return what we do have and follow up
|
||||
// with an update we send to the client.
|
||||
if (eagerResult?.stale || config.requestPolicy === 'cache-and-network') {
|
||||
return { ...eagerResult, executing: this.#maybeQueueDeferredExecute(config, eagerResult) }
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
return eagerResult
|
||||
}
|
||||
|
||||
// If we don't have a result here, queue this for execution if we haven't already,
|
||||
// and resolve with null
|
||||
return this.#maybeQueueDeferredExecute(config)
|
||||
}
|
||||
|
||||
// Invalidate individual fields in the GraphQL by hitting a "fake"
|
||||
// mutation and calling cache.invalidate on the internal cache
|
||||
// https://formidable.com/open-source/urql/docs/api/graphcache/#invalidate
|
||||
invalidate (...args: Parameters<Cache['invalidate']>) {
|
||||
return this.#cloudUrqlClient.mutation(`
|
||||
mutation Internal_cloudCacheInvalidate($args: JSON) {
|
||||
_cloudCacheInvalidate(args: $args)
|
||||
}
|
||||
`, { args }, {
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
// TODO: replace this with an exhange to filter out this request
|
||||
INTERNAL_REQUEST: JSON.stringify({ data: { _cloudCacheInvalidate: true } }),
|
||||
},
|
||||
},
|
||||
}).toPromise()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import chokidar from 'chokidar'
|
||||
import _ from 'lodash'
|
||||
|
||||
const debug = Debug('cypress:data-context:GitDataSource')
|
||||
const debugVerbose = Debug('cypress-verbose:data-context:GitDataSource')
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
@@ -237,7 +238,7 @@ export class GitDataSource {
|
||||
// Go through each file, updating our gitInfo cache and detecting which
|
||||
// entries have changed, to notify the UI
|
||||
for (const [i, file] of absolutePaths.entries()) {
|
||||
debug(`checking %s`, file)
|
||||
debugVerbose(`checking %s`, file)
|
||||
const current = this.#gitMeta.get(file)
|
||||
|
||||
// first check unstaged/untracked files
|
||||
|
||||
176
packages/data-context/src/sources/GraphQLDataSource.ts
Normal file
176
packages/data-context/src/sources/GraphQLDataSource.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { NexusGenAbstractTypeMembers } from '@packages/graphql/src/gen/nxs.gen'
|
||||
import debugLib from 'debug'
|
||||
import { execute, FieldNode, GraphQLResolveInfo, print, visit } from 'graphql'
|
||||
import type { core } from 'nexus'
|
||||
import type { DataContext } from '..'
|
||||
import { DocumentNodeBuilder } from '../util/DocumentNodeBuilder'
|
||||
|
||||
const debug = debugLib('cypress:data-context:GraphQLDataSource')
|
||||
const RESOLVED_SOURCE = Symbol('RESOLVED_SOURCE')
|
||||
|
||||
export interface PushResultParams {
|
||||
info: GraphQLResolveInfo
|
||||
ctx: DataContext
|
||||
result: any
|
||||
source?: any
|
||||
}
|
||||
|
||||
export interface PushQueryFragmentParams {
|
||||
source?: any
|
||||
result: any
|
||||
ctx: DataContext
|
||||
info: GraphQLResolveInfo
|
||||
}
|
||||
|
||||
export interface PushNodeFragmentParams extends PushQueryFragmentParams {
|
||||
source: any
|
||||
}
|
||||
|
||||
export class GraphQLDataSource {
|
||||
readonly RESOLVED_SOURCE = RESOLVED_SOURCE
|
||||
|
||||
resolveNode (nodeId: string, ctx: DataContext, info: GraphQLResolveInfo) {
|
||||
const [typeName] = this.#base64Decode(nodeId).split(':') as [NexusGenAbstractTypeMembers['Node'], string]
|
||||
|
||||
if (typeName?.startsWith('Cloud')) {
|
||||
return this.#delegateNodeToCloud(nodeId, ctx, info)
|
||||
}
|
||||
|
||||
switch (typeName) {
|
||||
case 'CurrentProject':
|
||||
return this.#proxyWithTypeName('CurrentProject', ctx.lifecycleManager)
|
||||
default:
|
||||
throw new Error(`Unable to read node for ${typeName}. Add a handler to GraphQLDataSource`)
|
||||
}
|
||||
}
|
||||
|
||||
#proxyWithTypeName <T extends NexusGenAbstractTypeMembers['Node'], O extends core.SourceValue<T>> (typename: T, obj: O) {
|
||||
// Ensure that we have __typename provided to handle the
|
||||
return new Proxy(obj, {
|
||||
get (target, prop, receiver) {
|
||||
if (prop === '__typename') {
|
||||
return typename
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* If we detect that the underlying type for a "node" field is a "Cloud" type,
|
||||
* then we want to issue it as a "cloudNode" query
|
||||
*/
|
||||
#delegateNodeToCloud (nodeId: string, ctx: DataContext, info: GraphQLResolveInfo) {
|
||||
const filteredNodes = info.fieldNodes.map((node) => {
|
||||
return visit(node, {
|
||||
Field (node) {
|
||||
if (node.name.value === 'node') {
|
||||
return { ...node, name: { kind: 'Name', value: 'cloudNode' } } as FieldNode
|
||||
}
|
||||
|
||||
return
|
||||
},
|
||||
InlineFragment: (node) => {
|
||||
// Remove any non-cloud types from the node
|
||||
if (node.typeCondition && !ctx.schemaCloud.getType(node.typeCondition.name.value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Execute the node field against the cloud schema
|
||||
return execute({
|
||||
schema: ctx.schemaCloud,
|
||||
contextValue: ctx,
|
||||
variableValues: info.variableValues,
|
||||
document: {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: filteredNodes,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#base64Decode (str: string) {
|
||||
return Buffer.from(str, 'base64').toString('utf8')
|
||||
}
|
||||
|
||||
pushResult ({ source, info, ctx, result }: PushResultParams) {
|
||||
if (info.parentType.name === 'Query') {
|
||||
this.#pushFragment({ result, ctx, info })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If it's a node, we can query as a Node field and push down the result that way
|
||||
if (info.parentType.getInterfaces().some((i) => i.name === 'Node') && result.id) {
|
||||
this.#pushFragment({ ctx, info, source, result }, true)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
#pushFragment (params: PushNodeFragmentParams | PushQueryFragmentParams, isNode: boolean = false) {
|
||||
const docBuilder = new DocumentNodeBuilder({
|
||||
parentType: params.info.parentType,
|
||||
fieldNodes: params.info.fieldNodes,
|
||||
}, isNode)
|
||||
|
||||
Promise.resolve(execute({
|
||||
schema: params.info.schema,
|
||||
document: isNode ? docBuilder.queryNode : docBuilder.query,
|
||||
rootValue: this.#makeRootValue(params, isNode, params.source),
|
||||
contextValue: params.ctx,
|
||||
})).then((result) => {
|
||||
debug(`pushFragment value %j`, result)
|
||||
|
||||
const data = isNode ? result.data?.node : result.data
|
||||
|
||||
// Take the result from executing the query, and push it down to the client
|
||||
// along with a fragment representing the part of the graph we're updating
|
||||
params.ctx.emitter.pushFragment([{
|
||||
target: params.info.parentType.name,
|
||||
fragment: print(docBuilder.clientWriteFragment),
|
||||
data,
|
||||
}])
|
||||
}).catch((e) => {
|
||||
debug(`pushFragment execution error %o`, e)
|
||||
})
|
||||
}
|
||||
|
||||
#makeRootValue (params: PushQueryFragmentParams, node: boolean, nodeSource?: any): any {
|
||||
// If we're resolving a node, we have a field named "node", with the resolved value
|
||||
// conforming to the "node" resolver
|
||||
if (node) {
|
||||
return {
|
||||
[RESOLVED_SOURCE]: true,
|
||||
node: new Proxy(nodeSource, {
|
||||
get (target, p, receiver) {
|
||||
if (p === '__typename') {
|
||||
return params.info.parentType.name
|
||||
}
|
||||
|
||||
return Reflect.get(target, p, receiver)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[RESOLVED_SOURCE]: true,
|
||||
[params.info.fieldName]: params.result,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export * from './EnvDataSource'
|
||||
export * from './ErrorDataSource'
|
||||
export * from './FileDataSource'
|
||||
export * from './GitDataSource'
|
||||
export * from './GraphQLDataSource'
|
||||
export * from './HtmlDataSource'
|
||||
export * from './MigrationDataSource'
|
||||
export * from './ProjectDataSource'
|
||||
|
||||
112
packages/data-context/src/util/DocumentNodeBuilder.ts
Normal file
112
packages/data-context/src/util/DocumentNodeBuilder.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { DocumentNode, FragmentDefinitionNode, GraphQLResolveInfo } from 'graphql'
|
||||
|
||||
/**
|
||||
* Builds a DocumentNode from a given GraphQLResolveInfo payload
|
||||
*
|
||||
* Used to generate a fragment to push down into the client-side cache
|
||||
*/
|
||||
export class DocumentNodeBuilder {
|
||||
readonly frag: FragmentDefinitionNode
|
||||
readonly clientWriteFragment: DocumentNode
|
||||
|
||||
constructor (info: Pick<GraphQLResolveInfo, 'fieldNodes' | 'parentType'>, isNode: boolean = false) {
|
||||
let selections = info.fieldNodes
|
||||
|
||||
if (isNode) {
|
||||
selections = [{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
}, ...selections]
|
||||
}
|
||||
|
||||
this.frag = {
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'GeneratedFragment' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: info.parentType.name },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections,
|
||||
},
|
||||
}
|
||||
|
||||
// The fragment used to write into the
|
||||
this.clientWriteFragment = {
|
||||
kind: 'Document',
|
||||
definitions: [this.frag],
|
||||
}
|
||||
}
|
||||
|
||||
get query (): DocumentNode {
|
||||
return {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
this.frag,
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'GeneratedFragment' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
get queryNode (): DocumentNode {
|
||||
return {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
this.frag,
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'node',
|
||||
},
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
value: { kind: 'StringValue', value: 'PUSH_FRAGMENT_PLACEHOLDER' },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: '__typename' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
},
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'GeneratedFragment' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
// created by autobarrel, do not modify directly
|
||||
|
||||
export * from './DocumentNodeBuilder'
|
||||
export * from './autoBindDebug'
|
||||
export * from './cached'
|
||||
export * from './config-file-updater'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CacheExchangeOpts } from '@urql/exchange-graphcache'
|
||||
import { relayPagination } from '@urql/exchange-graphcache/extras'
|
||||
|
||||
import type { GraphCacheConfig } from '../gen/graphcache-config.gen'
|
||||
|
||||
/**
|
||||
@@ -38,4 +40,9 @@ export const urqlCacheKeys: Partial<UrqlCacheKeys> = {
|
||||
GeneratedSpecError: () => null,
|
||||
GenerateSpecResponse: (data) => data.__typename,
|
||||
},
|
||||
resolvers: {
|
||||
CloudProject: {
|
||||
runs: relayPagination({ mergeMode: 'outwards' }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
spec: 'test/unit/**/*.spec.ts',
|
||||
watchFiles: ['test/**/*.ts', 'src/**/*.ts'],
|
||||
}
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
import 'mocha'
|
||||
import path from 'path'
|
||||
import fs from 'fs-extra'
|
||||
import Fixtures, { fixtureDirs } from '@tooling/system-tests'
|
||||
import { Response } from 'cross-fetch'
|
||||
import Fixtures, { fixtureDirs, scaffoldProject } from '@tooling/system-tests'
|
||||
import { DataContext, DataContextConfig } from '../../src'
|
||||
import { graphqlSchema } from '@packages/graphql/src/schema'
|
||||
import { remoteSchemaWrapped as schemaCloud } from '@packages/graphql/src/stitching/remoteSchemaWrapped'
|
||||
import type { BrowserApiShape } from '../../src/sources/BrowserDataSource'
|
||||
import type { AppApiShape, AuthApiShape, ElectronApiShape, LocalSettingsApiShape, ProjectApiShape } from '../../src/actions'
|
||||
import { InjectedConfigApi } from '../../src/data'
|
||||
import sinon from 'sinon'
|
||||
import { execute, parse } from 'graphql'
|
||||
import { getOperationName } from '@urql/core'
|
||||
import { CloudQuery } from '@packages/graphql/test/stubCloudTypes'
|
||||
import { remoteSchema } from '@packages/graphql/src/stitching/remoteSchema'
|
||||
|
||||
type SystemTestProject = typeof fixtureDirs[number]
|
||||
type SystemTestProjectPath<T extends SystemTestProject> = `${string}/system-tests/projects/${T}`
|
||||
|
||||
export { scaffoldProject }
|
||||
|
||||
export function getSystemTestProject<T extends typeof fixtureDirs[number]> (project: T): SystemTestProjectPath<T> {
|
||||
return path.join(__dirname, '..', '..', '..', '..', 'system-tests', 'projects', project) as SystemTestProjectPath<T>
|
||||
}
|
||||
@@ -29,9 +37,10 @@ export async function scaffoldMigrationProject (project: typeof fixtureDirs[numb
|
||||
return Fixtures.projectPath(project)
|
||||
}
|
||||
|
||||
export function createTestDataContext (mode: DataContextConfig['mode'] = 'run') {
|
||||
return new DataContext({
|
||||
export function createTestDataContext (mode: DataContextConfig['mode'] = 'run', stubFetch = true) {
|
||||
const ctx = new DataContext({
|
||||
schema: graphqlSchema,
|
||||
schemaCloud,
|
||||
mode,
|
||||
modeOptions: {},
|
||||
appApi: {} as AppApiShape,
|
||||
@@ -41,7 +50,9 @@ export function createTestDataContext (mode: DataContextConfig['mode'] = 'run')
|
||||
resetAuthState: sinon.stub(),
|
||||
} as unknown as AuthApiShape,
|
||||
configApi: {} as InjectedConfigApi,
|
||||
projectApi: {} as ProjectApiShape,
|
||||
projectApi: {
|
||||
closeActiveProject: sinon.stub(),
|
||||
} as unknown as ProjectApiShape,
|
||||
electronApi: {
|
||||
isMainWindowFocused: sinon.stub().returns(false),
|
||||
focusMainWindow: sinon.stub(),
|
||||
@@ -49,6 +60,38 @@ export function createTestDataContext (mode: DataContextConfig['mode'] = 'run')
|
||||
} as unknown as ElectronApiShape,
|
||||
browserApi: {
|
||||
focusActiveBrowserWindow: sinon.stub(),
|
||||
getBrowsers: sinon.stub().resolves([]),
|
||||
} as unknown as BrowserApiShape,
|
||||
})
|
||||
|
||||
if (stubFetch) {
|
||||
const origFetch = ctx.util.fetch
|
||||
|
||||
ctx.util.fetch = async function (url, init) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
if (String(url).endsWith('/test-runner-graphql')) {
|
||||
const { query, variables } = JSON.parse(String(init?.body))
|
||||
const document = parse(query)
|
||||
const operationName = getOperationName(document)
|
||||
|
||||
const result = await Promise.resolve(execute({
|
||||
operationName,
|
||||
variableValues: variables,
|
||||
rootValue: CloudQuery,
|
||||
contextValue: {
|
||||
__server__: ctx,
|
||||
},
|
||||
schema: remoteSchema,
|
||||
document,
|
||||
}))
|
||||
|
||||
return new Response(JSON.stringify(result), { status: 200 })
|
||||
}
|
||||
|
||||
return origFetch.call(this, url, init)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
304
packages/data-context/test/unit/sources/CloudDataSource.spec.ts
Normal file
304
packages/data-context/test/unit/sources/CloudDataSource.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import sinon from 'sinon'
|
||||
import { execute, parse } from 'graphql'
|
||||
import chaiAsPromised from 'chai-as-promised'
|
||||
import { Response } from 'cross-fetch'
|
||||
|
||||
import { DataContext } from '../../../src/DataContext'
|
||||
import { CloudDataResponse, CloudDataSource } from '../../../src/sources'
|
||||
import { createTestDataContext, scaffoldProject } from '../helper'
|
||||
import chai, { expect } from 'chai'
|
||||
import { ExecutionResult } from '@urql/core'
|
||||
|
||||
chai.use(chaiAsPromised)
|
||||
|
||||
const FAKE_USER_QUERY = parse(`{ cloudViewer { __typename id fullName email } }`)
|
||||
const FAKE_USER_RESPONSE = { data: { cloudViewer: { __typename: 'CloudUser', id: '1', fullName: 'test', email: 'test@example.com' } } }
|
||||
const FAKE_USER_WITH_OPTIONAL_MISSING = parse(`{ cloudViewer { __typename id fullName email cloudProfileUrl } }`)
|
||||
const FAKE_USER_WITH_OPTIONAL_MISSING_RESPONSE = { data: { cloudViewer: { __typename: 'CloudUser', id: '1', fullName: 'test', email: 'test@example.com', cloudProfileUrl: null } } }
|
||||
const FAKE_USER_WITH_OPTIONAL_RESOLVED_RESPONSE = { data: { cloudViewer: { __typename: 'CloudUser', id: '1', fullName: 'test', email: 'test@example.com', cloudProfileUrl: 'https://example.com' } } }
|
||||
const FAKE_USER_WITH_REQUIRED_MISSING = parse(`{ cloudViewer { __typename id fullName email userIsViewer } }`)
|
||||
const FAKE_USER_WITH_REQUIRED_RESOLVED_RESPONSE = { data: { cloudViewer: { __typename: 'CloudUser', id: '1', fullName: 'test', email: 'test@example.com', userIsViewer: true } } }
|
||||
const CLOUD_PROJECT_QUERY = parse(`{ currentProject {
|
||||
id
|
||||
cloudProject { __typename ... on CloudProject { id } }
|
||||
} }`)
|
||||
const CLOUD_PROJECT_RESPONSE = { data: { cloudProjectBySlug: { __typename: 'CloudProject', id: '1' } } }
|
||||
|
||||
describe('CloudDataSource', () => {
|
||||
let cloudDataSource: CloudDataSource
|
||||
let fetchStub: sinon.SinonStub
|
||||
let getUserStub: sinon.SinonStub
|
||||
let onErrorStub: sinon.SinonStub
|
||||
let ctx: DataContext
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.restore()
|
||||
fetchStub = sinon.stub()
|
||||
fetchStub.resolves(new Response(JSON.stringify(FAKE_USER_RESPONSE), { status: 200 }))
|
||||
getUserStub = sinon.stub()
|
||||
getUserStub.returns({ authToken: '1234' })
|
||||
onErrorStub = sinon.stub()
|
||||
ctx = createTestDataContext('open')
|
||||
cloudDataSource = new CloudDataSource({
|
||||
fetch: fetchStub,
|
||||
getUser: getUserStub,
|
||||
logout: sinon.stub(),
|
||||
onError: onErrorStub,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
ctx.destroy()
|
||||
})
|
||||
|
||||
describe('excecuteRemoteGraphQL', () => {
|
||||
it('returns immediately with { data: null } when no user is defined', () => {
|
||||
getUserStub.returns(null)
|
||||
const result = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
expect(result).to.eql({ data: null })
|
||||
expect(fetchStub).not.to.be.called
|
||||
})
|
||||
|
||||
it('issues a fetch request for the data when the user is defined', async () => {
|
||||
const result = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
const resolved = await result
|
||||
|
||||
expect(resolved.data).to.eql(FAKE_USER_RESPONSE.data)
|
||||
})
|
||||
|
||||
it('only issues a single fetch if the operation is called twice', async () => {
|
||||
const result1 = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
const result2 = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
expect(result1).to.eq(result2)
|
||||
|
||||
const resolved = await result1
|
||||
|
||||
expect(resolved.data).to.eql(FAKE_USER_RESPONSE.data)
|
||||
expect(fetchStub).to.have.been.calledOnce
|
||||
})
|
||||
|
||||
it('resolves eagerly with the cached data if the data has already been resolved', async () => {
|
||||
const result = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
await result
|
||||
|
||||
const immediateResult = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
expect((immediateResult as ExecutionResult).data).to.eql(FAKE_USER_RESPONSE.data)
|
||||
expect(fetchStub).to.have.been.calledOnce
|
||||
})
|
||||
|
||||
it('when there is a nullable field missing, resolves with the eager result & fetches for the rest', async () => {
|
||||
const result = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
await result
|
||||
|
||||
fetchStub.resolves(new Response(JSON.stringify(FAKE_USER_WITH_OPTIONAL_RESOLVED_RESPONSE), { status: 200 }))
|
||||
|
||||
const immediateResult = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_WITH_OPTIONAL_MISSING,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
expect((immediateResult as CloudDataResponse).data).to.eql(FAKE_USER_WITH_OPTIONAL_MISSING_RESPONSE.data)
|
||||
expect((immediateResult as CloudDataResponse).stale).to.eql(true)
|
||||
|
||||
const executingResponse = await (immediateResult as CloudDataResponse).executing
|
||||
|
||||
expect(executingResponse.data).to.eql(FAKE_USER_WITH_OPTIONAL_RESOLVED_RESPONSE.data)
|
||||
|
||||
expect(fetchStub).to.have.been.calledTwice
|
||||
})
|
||||
|
||||
it('when there is a non-nullable field missing, issues the remote query immediately', async () => {
|
||||
const result = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
await result
|
||||
|
||||
fetchStub.resolves(new Response(JSON.stringify(FAKE_USER_WITH_REQUIRED_RESOLVED_RESPONSE), { status: 200 }))
|
||||
|
||||
const requiredResult = cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_WITH_REQUIRED_MISSING,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
expect(requiredResult).to.be.instanceOf(Promise)
|
||||
|
||||
expect((await requiredResult).data).to.eql(FAKE_USER_WITH_REQUIRED_RESOLVED_RESPONSE.data)
|
||||
|
||||
expect(fetchStub).to.have.been.calledTwice
|
||||
})
|
||||
})
|
||||
|
||||
describe('isResolving', () => {
|
||||
it('returns false if we are not currently resolving the request', () => {
|
||||
const result = cloudDataSource.isResolving({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
expect(result).to.eql(false)
|
||||
})
|
||||
|
||||
it('returns true if we are currently resolving the request', () => {
|
||||
cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
const result = cloudDataSource.isResolving({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
expect(result).to.eql(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasResolved', () => {
|
||||
it('returns false if we have not resolved the data yet', () => {
|
||||
const result = cloudDataSource.hasResolved({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
expect(result).to.eql(false)
|
||||
})
|
||||
|
||||
it('returns true if we have resolved the data for the query', async () => {
|
||||
await cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
const result = cloudDataSource.hasResolved({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
expect(result).to.eql(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidate', () => {
|
||||
it('allows us to issue a cache.invalidate on individual fields in the cloud schema', async () => {
|
||||
await cloudDataSource.executeRemoteGraphQL({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
expect(cloudDataSource.hasResolved({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
})).to.eq(true)
|
||||
|
||||
await cloudDataSource.invalidate('Query', 'cloudViewer')
|
||||
|
||||
expect(cloudDataSource.hasResolved({
|
||||
document: FAKE_USER_QUERY,
|
||||
variables: {},
|
||||
})).to.eq(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delegateCloudField', () => {
|
||||
it('delegates a field to the remote schema, which calls executeRemoteGraphQL', async () => {
|
||||
fetchStub.resolves(new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(new Response(JSON.stringify(CLOUD_PROJECT_RESPONSE), { status: 200 }))
|
||||
}, 200)
|
||||
}))
|
||||
|
||||
Object.defineProperty(ctx, 'cloud', { value: cloudDataSource })
|
||||
|
||||
const dir = await scaffoldProject('component-tests')
|
||||
|
||||
const delegateCloudField = cloudDataSource.delegateCloudField
|
||||
|
||||
const delegateCloudSpy = sinon.stub(cloudDataSource, 'delegateCloudField').callsFake(async function (...args) {
|
||||
return delegateCloudField.apply(this, args)
|
||||
})
|
||||
|
||||
await ctx.actions.project.setCurrentProject(dir)
|
||||
|
||||
sinon.stub(ctx.project, 'projectId').resolves('abc1234')
|
||||
|
||||
const result = await execute({
|
||||
rootValue: {},
|
||||
document: CLOUD_PROJECT_QUERY,
|
||||
schema: ctx.schema,
|
||||
contextValue: ctx,
|
||||
})
|
||||
|
||||
expect(delegateCloudSpy).to.have.been.calledOnce
|
||||
|
||||
expect(result.data).to.eql({
|
||||
currentProject: {
|
||||
cloudProject: null,
|
||||
id: Buffer.from(`CurrentProject:${dir}`, 'utf8').toString('base64'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(await delegateCloudSpy.firstCall.returnValue)
|
||||
|
||||
const result2 = await execute({
|
||||
rootValue: {},
|
||||
document: CLOUD_PROJECT_QUERY,
|
||||
schema: ctx.schema,
|
||||
contextValue: ctx,
|
||||
})
|
||||
|
||||
expect(result2.data).to.eql({
|
||||
currentProject: {
|
||||
cloudProject: {
|
||||
__typename: 'CloudProject',
|
||||
id: '1',
|
||||
},
|
||||
id: Buffer.from(`CurrentProject:${dir}`, 'utf8').toString('base64'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fetchStub).to.have.been.calledOnce
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { expect } from 'chai'
|
||||
import dedent from 'dedent'
|
||||
import { execute, ExecutionResult, parse, subscribe } from 'graphql'
|
||||
import { DataContext } from '../../../src'
|
||||
import { createTestDataContext, scaffoldProject } from '../helper'
|
||||
|
||||
describe('GraphQLDataSource', () => {
|
||||
let ctx: DataContext
|
||||
let projectPath: string
|
||||
let pushFragmentIterator: AsyncIterableIterator<ExecutionResult>
|
||||
let pushFragmentNextVal: Promise<ExecutionResult>
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = createTestDataContext('open')
|
||||
ctx.update((d) => {
|
||||
d.user = {
|
||||
name: 'test tester',
|
||||
email: 'test@example.com',
|
||||
authToken: 'abc123',
|
||||
}
|
||||
})
|
||||
|
||||
projectPath = await scaffoldProject('component-tests')
|
||||
await ctx.actions.project.setCurrentProject(projectPath)
|
||||
ctx.project.projectId = async () => 'abc123'
|
||||
|
||||
pushFragmentIterator = await Promise.resolve(subscribe({
|
||||
schema: ctx.schema,
|
||||
contextValue: ctx,
|
||||
document: parse(`subscription {
|
||||
pushFragment {
|
||||
target
|
||||
fragment
|
||||
data
|
||||
}
|
||||
}`),
|
||||
})) as AsyncIterableIterator<ExecutionResult>
|
||||
|
||||
pushFragmentNextVal = pushFragmentIterator.next().then(({ value }) => value)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
pushFragmentIterator.return()
|
||||
ctx.destroy()
|
||||
})
|
||||
|
||||
function executeQuery (query: string) {
|
||||
return Promise.resolve(execute({
|
||||
document: parse(query),
|
||||
schema: ctx.schema,
|
||||
contextValue: ctx,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('pushQueryFragment', () => {
|
||||
it('calls "pushQueryFragment" on a query field that has resolved', async () => {
|
||||
const result = await executeQuery(`{ cloudViewer { id } }`)
|
||||
|
||||
// Initial cloudViewer result returns null
|
||||
expect(result.data.cloudViewer).to.eq(null)
|
||||
|
||||
const { target, data, fragment } = (await pushFragmentNextVal).data.pushFragment[0]
|
||||
|
||||
expect(target).to.eq('Query')
|
||||
expect(data).to.eql({
|
||||
cloudViewer: {
|
||||
id: 'Q2xvdWRVc2VyOjE=',
|
||||
},
|
||||
})
|
||||
|
||||
expect(fragment.trim()).to.eq(dedent`
|
||||
fragment GeneratedFragment on Query {
|
||||
cloudViewer {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushNodeFragment', () => {
|
||||
it('calls "pushNodeFragment" on a node field that eventually resolves', async () => {
|
||||
const result = await executeQuery(`{ currentProject { id cloudProject { __typename ... on CloudProject { id name } } } }`)
|
||||
|
||||
// Initial cloudProject result returns null
|
||||
expect(result.data.currentProject.cloudProject).to.eq(null)
|
||||
|
||||
const { target, data, fragment } = (await pushFragmentNextVal).data.pushFragment[0]
|
||||
|
||||
expect(target).to.eq('CurrentProject')
|
||||
expect(data).to.eql({
|
||||
__typename: 'CurrentProject',
|
||||
cloudProject: {
|
||||
__typename: 'CloudProject',
|
||||
id: 'Q2xvdWRQcm9qZWN0OjU=',
|
||||
name: 'cloud-project-abc123',
|
||||
},
|
||||
id: Buffer.from(`CurrentProject:${projectPath}`, 'utf8').toString('base64'),
|
||||
})
|
||||
|
||||
expect(fragment.trim()).to.eq(dedent`
|
||||
fragment GeneratedFragment on CurrentProject {
|
||||
id
|
||||
cloudProject {
|
||||
__typename
|
||||
... on CloudProject {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('calls "pushNodeFragment" on a node field that eagerly resolves, but has an updated value', async () => {
|
||||
const result = await executeQuery(`{ cloudViewer { id } }`)
|
||||
|
||||
// Initial cloudProject result returns null
|
||||
expect(result.data.cloudViewer).to.eql(null)
|
||||
await pushFragmentNextVal
|
||||
|
||||
pushFragmentNextVal = pushFragmentIterator.next().then(({ value }) => value)
|
||||
|
||||
const result2 = await executeQuery(`{ cloudViewer { id cloudOrganizationsUrl } }`)
|
||||
|
||||
// Initial cloudProject result returns null
|
||||
expect(result2.data.cloudViewer).to.eql({ id: 'Q2xvdWRVc2VyOjE=', cloudOrganizationsUrl: null })
|
||||
|
||||
const { target, data, fragment } = (await pushFragmentNextVal).data.pushFragment[0]
|
||||
|
||||
expect(target).to.eq('Query')
|
||||
expect(data).to.eql({
|
||||
cloudViewer: {
|
||||
id: 'Q2xvdWRVc2VyOjE=',
|
||||
cloudOrganizationsUrl: 'http://dummy.cypress.io/organizations',
|
||||
},
|
||||
})
|
||||
|
||||
expect(fragment.trim()).to.eq(dedent`
|
||||
fragment GeneratedFragment on Query {
|
||||
cloudViewer {
|
||||
id
|
||||
cloudOrganizationsUrl
|
||||
}
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
106
packages/data-context/test/unit/util/DocumentNodeBuilder.spec.ts
Normal file
106
packages/data-context/test/unit/util/DocumentNodeBuilder.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { graphqlSchema } from '@packages/graphql'
|
||||
import { expect } from 'chai'
|
||||
import dedent from 'dedent'
|
||||
import { FieldNode, GraphQLObjectType, OperationDefinitionNode, parse, print } from 'graphql'
|
||||
import { DocumentNodeBuilder } from '../../../src'
|
||||
|
||||
const CLOUD_VIEWER_QUERY = parse(`
|
||||
query {
|
||||
cloudViewer {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const CLOUD_PROJECT_QUERY = parse(`
|
||||
query {
|
||||
currentProject {
|
||||
id
|
||||
cloudProject {
|
||||
__typename
|
||||
... on CloudProject {
|
||||
id
|
||||
recordKeys {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
describe('DocumentNodeBuilder', () => {
|
||||
it('frag: should generate a fragment', () => {
|
||||
const docNodeBuilder = new DocumentNodeBuilder({
|
||||
fieldNodes: ((CLOUD_VIEWER_QUERY.definitions[0] as OperationDefinitionNode).selectionSet.selections as ReadonlyArray<FieldNode>),
|
||||
parentType: graphqlSchema.getQueryType(),
|
||||
})
|
||||
|
||||
expect(print(docNodeBuilder.frag)).to.eql(dedent`
|
||||
fragment GeneratedFragment on Query {
|
||||
cloudViewer {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
}
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('query: should create a query + fragment', () => {
|
||||
const docNodeBuilder = new DocumentNodeBuilder({
|
||||
fieldNodes: ((CLOUD_VIEWER_QUERY.definitions[0] as OperationDefinitionNode).selectionSet.selections as ReadonlyArray<FieldNode>),
|
||||
parentType: graphqlSchema.getQueryType(),
|
||||
})
|
||||
|
||||
expect(print(docNodeBuilder.query).trimEnd()).to.eql(dedent`
|
||||
fragment GeneratedFragment on Query {
|
||||
cloudViewer {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
...GeneratedFragment
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('queryNode: should create a node query + fragment', () => {
|
||||
const selections = ((CLOUD_PROJECT_QUERY.definitions[0] as OperationDefinitionNode).selectionSet.selections as ReadonlyArray<FieldNode>)
|
||||
|
||||
const docNodeBuilder = new DocumentNodeBuilder({
|
||||
fieldNodes: (selections[0].selectionSet.selections) as ReadonlyArray<FieldNode>,
|
||||
parentType: graphqlSchema.getType('CloudProject') as GraphQLObjectType,
|
||||
})
|
||||
|
||||
expect(print(docNodeBuilder.queryNode).trimRight()).to.eql(dedent`
|
||||
fragment GeneratedFragment on CloudProject {
|
||||
id
|
||||
cloudProject {
|
||||
__typename
|
||||
... on CloudProject {
|
||||
id
|
||||
recordKeys {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
node(id: "PUSH_FRAGMENT_PLACEHOLDER") {
|
||||
__typename
|
||||
id
|
||||
...GeneratedFragment
|
||||
}
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -10,10 +10,10 @@ import * as inspector from 'inspector'
|
||||
import sinonChai from '@cypress/sinon-chai'
|
||||
import sinon from 'sinon'
|
||||
import fs from 'fs-extra'
|
||||
import { buildSchema, execute, GraphQLError, parse } from 'graphql'
|
||||
import { buildSchema, execute, ExecutionResult, GraphQLError, parse } from 'graphql'
|
||||
import { Response } from 'cross-fetch'
|
||||
|
||||
import { CloudRunQuery } from '../support/mock-graphql/stubgql-CloudTypes'
|
||||
import { CloudQuery } from '@packages/graphql/test/stubCloudTypes'
|
||||
import { getOperationName } from '@urql/core'
|
||||
import pDefer from 'p-defer'
|
||||
|
||||
@@ -199,12 +199,12 @@ async function makeE2ETasks () {
|
||||
|
||||
operationCount[operationName ?? 'unknown'] = operationCount[operationName ?? 'unknown'] ?? 0
|
||||
|
||||
let result = await execute({
|
||||
let result: ExecutionResult | Response = await execute({
|
||||
operationName,
|
||||
document,
|
||||
variableValues: variables,
|
||||
schema: cloudSchema,
|
||||
rootValue: CloudRunQuery,
|
||||
rootValue: CloudQuery,
|
||||
contextValue: {
|
||||
__server__: ctx,
|
||||
},
|
||||
@@ -221,6 +221,7 @@ async function makeE2ETasks () {
|
||||
query,
|
||||
result,
|
||||
callCount: operationCount[operationName ?? 'unknown'],
|
||||
Response,
|
||||
}, testState)
|
||||
} catch (e) {
|
||||
const err = e as Error
|
||||
@@ -229,6 +230,10 @@ async function makeE2ETasks () {
|
||||
}
|
||||
}
|
||||
|
||||
if (result instanceof Response) {
|
||||
return result
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result), { status: 200 })
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { E2ETaskMap } from '../e2ePluginSetup'
|
||||
import type { SinonStub } from 'sinon'
|
||||
import type sinon from 'sinon'
|
||||
import type pDefer from 'p-defer'
|
||||
import type { Response } from 'cross-fetch'
|
||||
|
||||
configure({ testIdAttribute: 'data-cy' })
|
||||
|
||||
@@ -45,9 +46,10 @@ export interface RemoteGraphQLInterceptPayload {
|
||||
document: DocumentNode
|
||||
result: ExecutionResult
|
||||
callCount: number
|
||||
Response: typeof Response
|
||||
}
|
||||
|
||||
export type RemoteGraphQLInterceptor = (obj: RemoteGraphQLInterceptPayload, testState: Record<string, any>) => ExecutionResult | Promise<ExecutionResult>
|
||||
export type RemoteGraphQLInterceptor = (obj: RemoteGraphQLInterceptPayload, testState: Record<string, any>) => ExecutionResult | Promise<ExecutionResult> | Response
|
||||
|
||||
export interface FindBrowsersOptions {
|
||||
// Array of FoundBrowser objects that will be used as the mock output
|
||||
@@ -136,7 +138,7 @@ declare global {
|
||||
/**
|
||||
* Visits the Cypress app, for Cypress-in-Cypress testing
|
||||
*/
|
||||
visitApp(href?: string): Chainable<AUTWindow>
|
||||
visitApp(href?: string, opts?: Partial<Cypress.VisitOptions>): Chainable<AUTWindow>
|
||||
/**
|
||||
* Visits the Cypress launchpad
|
||||
*/
|
||||
@@ -309,7 +311,7 @@ function startAppServer (mode: 'component' | 'e2e' = 'e2e', options: { skipMocki
|
||||
})
|
||||
}
|
||||
|
||||
function visitApp (href?: string) {
|
||||
function visitApp (href?: string, opts?: Partial<Cypress.VisitOptions>) {
|
||||
const { e2e_serverPort } = Cypress.env()
|
||||
|
||||
if (!e2e_serverPort) {
|
||||
@@ -326,7 +328,7 @@ function visitApp (href?: string) {
|
||||
|
||||
return config.clientRoute
|
||||
}).then((clientRoute) => {
|
||||
return cy.visit(`http://localhost:${e2e_serverPort}${clientRoute || '/__/'}#${href || ''}`)
|
||||
return cy.visit(`http://localhost:${e2e_serverPort}${clientRoute || '/__/'}#${href || ''}`, opts)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
} from '../generated/test-graphql-types.gen'
|
||||
import { resetTestNodeIdx } from './clientTestUtils'
|
||||
import { stubBrowsers } from './stubgql-Browser'
|
||||
import * as cloudTypes from './stubgql-CloudTypes'
|
||||
import * as cloudTypes from '@packages/graphql/test/stubCloudTypes'
|
||||
import { createTestCurrentProject, createTestGlobalProject, stubGlobalProject } from './stubgql-Project'
|
||||
import { allBundlers } from './stubgql-Wizard'
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
GlobalProject,
|
||||
} from '../generated/test-graphql-types.gen'
|
||||
import { testNodeId } from './clientTestUtils'
|
||||
import { CloudProjectStubs } from './stubgql-CloudTypes'
|
||||
import { CloudProjectStubs } from '@packages/graphql/test/stubCloudTypes'
|
||||
import { stubBrowsers } from './stubgql-Browser'
|
||||
|
||||
export const createTestGlobalProject = (title: string, additionalConfig: Partial<GlobalProject> = {}): GlobalProject => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MaybeResolver } from './clientTestUtils'
|
||||
import { stubMutation } from './stubgql-Mutation'
|
||||
import { stubQuery } from './stubgql-Query'
|
||||
import { stubGlobalProject, stubProject } from './stubgql-Project'
|
||||
import { CloudOrganizationStubs, CloudProjectStubs, CloudRecordKeyStubs, CloudRunStubs, CloudUserStubs } from './stubgql-CloudTypes'
|
||||
import { CloudOrganizationStubs, CloudProjectStubs, CloudRecordKeyStubs, CloudRunStubs, CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
|
||||
import { stubMigration } from './stubgql-Migration'
|
||||
import type { CodegenTypeMap } from '../generated/test-graphql-types.gen'
|
||||
import { StubErrorWrapper } from './stubgql-ErrorWrapper'
|
||||
|
||||
@@ -111,4 +111,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Exchange, Client } from '@urql/core'
|
||||
import {
|
||||
import { pipe, subscribe } from 'wonka'
|
||||
import { Exchange, Client, gql,
|
||||
createClient,
|
||||
dedupExchange,
|
||||
errorExchange,
|
||||
@@ -14,13 +14,13 @@ import { createClient as createWsClient } from 'graphql-ws'
|
||||
|
||||
import { cacheExchange as graphcacheExchange } from '@urql/exchange-graphcache'
|
||||
import { urqlCacheKeys } from '@packages/data-context/src/util/urqlCacheKeys'
|
||||
|
||||
import { urqlSchema } from '../generated/urql-introspection.gen'
|
||||
import { urqlSchema } from '@packages/data-context/src/gen/urql-introspection.gen'
|
||||
|
||||
import { pubSubExchange } from './urqlExchangePubsub'
|
||||
import { namedRouteExchange } from './urqlExchangeNamedRoute'
|
||||
import type { SpecFile, AutomationElementId, Browser } from '@packages/types'
|
||||
import { urqlFetchSocketAdapter } from './urqlFetchSocketAdapter'
|
||||
import type { DocumentNode } from 'graphql'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
@@ -29,6 +29,15 @@ export function makeCacheExchange (schema: any = urqlSchema) {
|
||||
...urqlCacheKeys,
|
||||
schema,
|
||||
updates: {
|
||||
Subscription: {
|
||||
pushFragment (parent, args, cache, info) {
|
||||
const { pushFragment } = parent as { pushFragment: { id?: string, fragment: DocumentNode, data: any, typename: string }[] }
|
||||
|
||||
for (const toPush of pushFragment) {
|
||||
cache.writeFragment(toPush.fragment, toPush.data)
|
||||
}
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
logout (parent, args, cache, info) {
|
||||
// Invalidate all queries locally upon logging out, to ensure there's no stale cloud data
|
||||
@@ -156,6 +165,22 @@ export async function makeUrqlClient (config: UrqlClientConfig): Promise<Client>
|
||||
|
||||
await connectPromise
|
||||
|
||||
// https://formidable.com/open-source/urql/docs/advanced/subscriptions/#one-off-subscriptions
|
||||
pipe(
|
||||
client.subscription(gql`
|
||||
subscription urqlClient_PushFragment {
|
||||
pushFragment {
|
||||
target
|
||||
fragment
|
||||
data
|
||||
}
|
||||
}
|
||||
`),
|
||||
subscribe((val) => {
|
||||
// console.log(val)
|
||||
}),
|
||||
)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
"test-integration": "mocha -r @packages/ts/register test/integration/**/*.spec.ts --config ./test/.mocharc.js --exit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@graphql-tools/batch-delegate": "8.1.0",
|
||||
"@graphql-tools/delegate": "8.2.1",
|
||||
"@graphql-tools/utils": "8.2.3",
|
||||
"@graphql-tools/wrap": "8.1.1",
|
||||
"dedent": "^0.7.0",
|
||||
"express": "4.17.1",
|
||||
|
||||
@@ -942,6 +942,12 @@ enum MigrationStepEnum {
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
"""Internal use only, clears the cloud cache"""
|
||||
_clearCloudCache: Boolean
|
||||
|
||||
"""Used internally to update the URQL cache in the CloudDataSource"""
|
||||
_cloudCacheInvalidate(args: JSON): Boolean
|
||||
|
||||
"""Add project to projects array and cache it"""
|
||||
addProject(
|
||||
"""Whether to open the project when added"""
|
||||
@@ -1048,6 +1054,14 @@ type Mutation {
|
||||
"""show the launchpad windows"""
|
||||
reconfigureProject: Boolean!
|
||||
|
||||
"""
|
||||
Signal that we are explicitly refetching remote data and should not use the server cache
|
||||
"""
|
||||
refetchRemote: Query
|
||||
|
||||
"""Clears the cloudViewer cache to refresh the organizations"""
|
||||
refreshOrganizations: Query
|
||||
|
||||
"""Remove project from projects array and cache"""
|
||||
removeProject(path: String!): Query
|
||||
|
||||
@@ -1155,6 +1169,12 @@ type ProjectPreferences {
|
||||
testingType: String
|
||||
}
|
||||
|
||||
type PushFragmentPayload {
|
||||
data: JSON
|
||||
fragment: JSON!
|
||||
target: String!
|
||||
}
|
||||
|
||||
"""The root "Query" type containing all entry fields for our querying"""
|
||||
type Query {
|
||||
"""The latest state of the auth process"""
|
||||
@@ -1200,6 +1220,7 @@ type Query {
|
||||
|
||||
"""Metadata about the migration, null if we aren't showing it"""
|
||||
migration: Migration
|
||||
node(id: ID!): Node
|
||||
|
||||
"""Whether the project was specified from the --project flag"""
|
||||
projectRootFromCI: Boolean!
|
||||
@@ -1303,6 +1324,11 @@ type Subscription {
|
||||
"""
|
||||
gitInfoChange: [Spec]
|
||||
|
||||
"""
|
||||
When we have resolved a section of a query, and want to update the local normalized cache, we "push" the fragment to the frontend to merge in the client side cache
|
||||
"""
|
||||
pushFragment: [PushFragmentPayload]
|
||||
|
||||
"""Issued when the watched specs for the project changes"""
|
||||
specsChange: CurrentProject
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ export { execute, parse, print } from 'graphql'
|
||||
|
||||
export { remoteSchemaWrapped } from './stitching/remoteSchemaWrapped'
|
||||
|
||||
export type { RemoteExecutionRoot } from './stitching/remoteSchemaExecutor'
|
||||
export type { RemoteExecutionRoot } from './stitching/remoteSchemaWrapped'
|
||||
|
||||
@@ -48,7 +48,12 @@ export async function makeGraphQLServer () {
|
||||
|
||||
switch (operationName) {
|
||||
case 'orgCreated':
|
||||
ctx.emitter.cloudViewerChange()
|
||||
ctx.cloud.invalidate('Query', 'cloudViewer')
|
||||
.then(() => {
|
||||
ctx.emitter.cloudViewerChange()
|
||||
})
|
||||
.catch(ctx.logTraceError)
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { plugin } from 'nexus'
|
||||
import debugLib from 'debug'
|
||||
import { getNamedType, isNonNullType } from 'graphql'
|
||||
import { defaultFieldResolver, getNamedType, isNonNullType } from 'graphql'
|
||||
import type { DataContext } from '@packages/data-context'
|
||||
import { remoteSchema } from '../stitching/remoteSchema'
|
||||
|
||||
@@ -8,9 +8,26 @@ 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')
|
||||
|
||||
export const nexusDeferResolveGuard = plugin({
|
||||
name: 'nexusDeferResolveGuard',
|
||||
onCreateFieldResolver () {
|
||||
return (source, args, ctx, info, next) => {
|
||||
// If we are hitting the resolver with the "source" value, we can just resolve with this,
|
||||
// no need to continue to the rest of the resolver stack, we just need to continue completing
|
||||
// the execution of the field
|
||||
if (source?.[ctx.graphql.RESOLVED_SOURCE]) {
|
||||
debug(`Resolving %s for pushFragment with %j`, info.fieldName, source[info.fieldName])
|
||||
|
||||
return defaultFieldResolver(source, args, ctx, info)
|
||||
}
|
||||
|
||||
return next(source, args, ctx, info)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -70,7 +87,7 @@ export const nexusDeferIfNotLoadedPlugin = plugin({
|
||||
|
||||
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) => {
|
||||
Promise.resolve(next(source, args, ctx, info)).then(async (result) => {
|
||||
if (!didRace) {
|
||||
debug(`Racing %s resolved immediately`, qualifiedField)
|
||||
|
||||
@@ -79,29 +96,7 @@ export const nexusDeferIfNotLoadedPlugin = plugin({
|
||||
|
||||
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)
|
||||
}
|
||||
ctx.graphql.pushResult({ source, result, info, ctx })
|
||||
}).catch((e) => {
|
||||
debug(`Remote execution error %o`, e)
|
||||
|
||||
|
||||
@@ -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, nexusDeferIfNotLoadedPlugin } from './plugins'
|
||||
import { mutationErrorPlugin, nexusDebugLogPlugin, nexusSlowGuardPlugin, nexusDeferIfNotLoadedPlugin, nexusDeferResolveGuard } from './plugins'
|
||||
|
||||
const isCodegen = Boolean(process.env.CYPRESS_INTERNAL_NEXUS_CODEGEN)
|
||||
|
||||
@@ -30,6 +30,7 @@ export const graphqlSchema = makeSchema({
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
nexusDeferResolveGuard,
|
||||
nexusSlowGuardPlugin,
|
||||
nexusDeferIfNotLoadedPlugin,
|
||||
nexusDebugLogPlugin,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { PACKAGE_MANAGERS } from '@packages/types'
|
||||
import { enumType, nonNull, objectType, stringArg } from 'nexus'
|
||||
import path from 'path'
|
||||
import { BrowserStatusEnum, FileExtensionEnum } from '..'
|
||||
import { cloudProjectBySlug } from '../../stitching/remoteGraphQLCalls'
|
||||
import { TestingTypeEnum } from '../enumTypes/gql-WizardEnums'
|
||||
import { Browser } from './gql-Browser'
|
||||
import { CodeGenGlobs } from './gql-CodeGenGlobs'
|
||||
@@ -68,7 +67,12 @@ export const CurrentProject = objectType({
|
||||
return null
|
||||
}
|
||||
|
||||
return cloudProjectBySlug(projectId, ctx, info)
|
||||
return ctx.cloud.delegateCloudField({
|
||||
field: 'cloudProjectBySlug',
|
||||
args: { slug: projectId },
|
||||
ctx,
|
||||
info,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -570,7 +570,13 @@ export const mutation = mutationType({
|
||||
projectId: nonNull(stringArg()),
|
||||
},
|
||||
resolve: async (_, args, ctx) => {
|
||||
await ctx.actions.project.setProjectIdInConfigFile(args.projectId)
|
||||
try {
|
||||
await ctx.actions.project.setProjectIdInConfigFile(args.projectId)
|
||||
} catch {
|
||||
// We were unable to set the project id, the error isn't useful
|
||||
// to show the user here, because they're prompted to update the id manually
|
||||
return null
|
||||
}
|
||||
|
||||
// Wait for the project config to be reloaded
|
||||
await ctx.lifecycleManager.refreshLifecycle()
|
||||
@@ -640,5 +646,34 @@ export const mutation = mutationType({
|
||||
return {}
|
||||
},
|
||||
})
|
||||
|
||||
t.field('refreshOrganizations', {
|
||||
type: Query,
|
||||
description: 'Clears the cloudViewer cache to refresh the organizations',
|
||||
resolve: async (source, args, ctx) => {
|
||||
await ctx.cloud.invalidate('Query', 'cloudViewer')
|
||||
|
||||
return {}
|
||||
},
|
||||
})
|
||||
|
||||
t.field('refetchRemote', {
|
||||
type: Query,
|
||||
description: 'Signal that we are explicitly refetching remote data and should not use the server cache',
|
||||
resolve: () => {
|
||||
return {
|
||||
requestPolicy: 'network-only',
|
||||
} as const
|
||||
},
|
||||
})
|
||||
|
||||
t.boolean('_clearCloudCache', {
|
||||
description: 'Internal use only, clears the cloud cache',
|
||||
resolve: (source, args, ctx) => {
|
||||
ctx.cloud.reset()
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { objectType } from 'nexus'
|
||||
import { idArg, nonNull, objectType } from 'nexus'
|
||||
import { ProjectLike, ScaffoldedFile } from '..'
|
||||
import { CurrentProject } from './gql-CurrentProject'
|
||||
import { DevState } from './gql-DevState'
|
||||
@@ -106,6 +106,17 @@ export const Query = objectType({
|
||||
type: ScaffoldedFile,
|
||||
resolve: (_, args, ctx) => ctx.coreData.scaffoldedFiles,
|
||||
})
|
||||
|
||||
t.field('node', {
|
||||
type: 'Node',
|
||||
args: {
|
||||
id: nonNull(idArg()),
|
||||
},
|
||||
resolve: (root, args, ctx, info) => {
|
||||
// Cast as any, because this is extremely difficult to type correctly
|
||||
return ctx.graphql.resolveNode(args.id, ctx, info) as any
|
||||
},
|
||||
})
|
||||
},
|
||||
sourceType: {
|
||||
module: '@packages/graphql',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { list, subscriptionType } from 'nexus'
|
||||
import type { PushFragmentData } from '@packages/data-context/src/actions'
|
||||
import { list, objectType, subscriptionType } from 'nexus'
|
||||
import { CurrentProject, DevState, Query } from '.'
|
||||
import { Spec } from './gql-Spec'
|
||||
|
||||
@@ -7,8 +8,12 @@ export const Subscription = subscriptionType({
|
||||
t.field('authChange', {
|
||||
type: Query,
|
||||
description: 'Triggered when the auth state changes',
|
||||
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('authChange'),
|
||||
resolve: (source, args, ctx) => ({}),
|
||||
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('authChange', { sendInitial: false }),
|
||||
resolve: (source, args, ctx) => {
|
||||
return {
|
||||
requestPolicy: 'network-only',
|
||||
} as const
|
||||
},
|
||||
})
|
||||
|
||||
t.field('baseErrorChange', {
|
||||
@@ -28,7 +33,7 @@ export const Subscription = subscriptionType({
|
||||
t.field('cloudViewerChange', {
|
||||
type: Query,
|
||||
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'),
|
||||
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('cloudViewerChange', { sendInitial: false }),
|
||||
resolve: (source, args, ctx) => {
|
||||
return {
|
||||
requestPolicy: 'network-only',
|
||||
@@ -79,5 +84,19 @@ export const Subscription = subscriptionType({
|
||||
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('branchChange'),
|
||||
resolve: (source, args, ctx) => ctx.lifecycleManager,
|
||||
})
|
||||
|
||||
t.field('pushFragment', {
|
||||
description: 'When we have resolved a section of a query, and want to update the local normalized cache, we "push" the fragment to the frontend to merge in the client side cache',
|
||||
type: list(objectType({
|
||||
name: 'PushFragmentPayload',
|
||||
definition (t) {
|
||||
t.nonNull.string('target')
|
||||
t.nonNull.json('fragment')
|
||||
t.json('data')
|
||||
},
|
||||
})),
|
||||
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('pushFragment', { sendInitial: false }),
|
||||
resolve: (source: PushFragmentData[], args, ctx) => source,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'
|
||||
import type { GraphQLResolveInfo } from 'graphql'
|
||||
import { remoteSchemaWrapped } from './remoteSchemaWrapped'
|
||||
import type { Query as CloudQuery } from '../gen/cloud-source-types.gen'
|
||||
import type { DataContext } from '@packages/data-context'
|
||||
import { pathToArray } from 'graphql/jsutils/Path'
|
||||
|
||||
type ArrVal<T> = T extends Array<infer U> ? U : never
|
||||
|
||||
type PotentialFields = Exclude<keyof CloudQuery, '__typename'>
|
||||
|
||||
interface FieldArgMapping {
|
||||
cloudProjectsBySlugs: string
|
||||
}
|
||||
|
||||
interface DelegateToRemoteQueryBatchedConfig<F extends KnownBatchFields> {
|
||||
fieldName: F
|
||||
info: GraphQLResolveInfo
|
||||
rootValue?: object
|
||||
key?: FieldArgMapping[F]
|
||||
context: DataContext
|
||||
}
|
||||
|
||||
type KnownBatchFields = PotentialFields & keyof FieldArgMapping
|
||||
|
||||
const FieldConfig: Record<KnownBatchFields, string> = {
|
||||
cloudProjectsBySlugs: 'slugs',
|
||||
}
|
||||
|
||||
const IS_PROD = process.env.CYPRESS_INTERNAL_CLOUD_ENV === 'production'
|
||||
|
||||
export function cloudProjectBySlug (slug: string, context: DataContext, info: GraphQLResolveInfo) {
|
||||
return delegateToRemoteQueryBatched<'cloudProjectsBySlugs'>({
|
||||
info,
|
||||
key: slug,
|
||||
fieldName: 'cloudProjectsBySlugs',
|
||||
context,
|
||||
rootValue: {
|
||||
requestPolicy: 'cache-and-network',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function delegateToRemoteQueryBatched<T extends KnownBatchFields> (config: DelegateToRemoteQueryBatchedConfig<T>): Promise<ArrVal<CloudQuery[T]> | null | Error> {
|
||||
try {
|
||||
return await batchDelegateToSchema({
|
||||
schema: remoteSchemaWrapped,
|
||||
info: config.info,
|
||||
context: config.context,
|
||||
rootValue: config.rootValue ?? {},
|
||||
operation: 'query',
|
||||
operationName: `${config.info.operation.name?.value ?? 'Unnamed'}_${pathToArray(config.info.path).join('_')}_batched`,
|
||||
fieldName: config.fieldName,
|
||||
key: config.key,
|
||||
argsFromKeys: (keys) => ({ [FieldConfig[config.fieldName]]: keys }),
|
||||
})
|
||||
} catch (e) {
|
||||
if (IS_PROD) {
|
||||
config.context.logTraceError(e)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return e as Error
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,19 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { buildSchema } from 'graphql'
|
||||
|
||||
const LOCAL_SCHEMA_EXTENSIONS = `
|
||||
scalar JSON
|
||||
|
||||
extend type Mutation {
|
||||
"""
|
||||
Used internally to update the URQL cache in the CloudDataSource
|
||||
"""
|
||||
_cloudCacheInvalidate(args: JSON): Boolean
|
||||
}
|
||||
`
|
||||
|
||||
// Get the Remote schema we've sync'ed locally
|
||||
export const remoteSchema = buildSchema(
|
||||
fs.readFileSync(path.join(__dirname, '../../schemas', 'cloud.graphql'), 'utf-8'),
|
||||
fs.readFileSync(path.join(__dirname, '../../schemas', 'cloud.graphql'), 'utf-8') + LOCAL_SCHEMA_EXTENSIONS,
|
||||
{ assumeValid: true },
|
||||
)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
* @returns
|
||||
*/
|
||||
export const remoteSchemaExecutor = async (obj: Record<string, any>) => {
|
||||
const { document: _document, operationType, variables, context: _context, rootValue } = obj
|
||||
|
||||
const document: DocumentNode = _document
|
||||
const context: DataContext = _context
|
||||
|
||||
if (!context?.user) {
|
||||
return { data: null }
|
||||
}
|
||||
|
||||
const requestPolicy: RequestPolicy | undefined = rootValue?.requestPolicy ?? null
|
||||
|
||||
try {
|
||||
const executorResult = await context.cloud.executeRemoteGraphQL({
|
||||
operationType,
|
||||
document,
|
||||
variables,
|
||||
query: print(document),
|
||||
requestPolicy,
|
||||
})
|
||||
|
||||
context.debug('executorResult %o', executorResult)
|
||||
|
||||
return executorResult
|
||||
} catch (error) {
|
||||
if (error.networkError?.message === 'Unauthorized' || error.graphQLErrors.some((e: Error) => e.message === 'Unauthorized')) {
|
||||
await context.actions.auth.logout()
|
||||
|
||||
return { data: null }
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,74 @@
|
||||
import { delegateToSchema } from '@graphql-tools/delegate'
|
||||
import { wrapSchema } from '@graphql-tools/wrap'
|
||||
import type { GraphQLResolveInfo } from 'graphql'
|
||||
import { pathToArray } from 'graphql/jsutils/Path'
|
||||
import type { DataContext } from '@packages/data-context'
|
||||
import type { RequestPolicy } from '@urql/core'
|
||||
import assert from 'assert'
|
||||
import debugLib from 'debug'
|
||||
import { BREAK, OperationDefinitionNode, visit } from 'graphql'
|
||||
import { remoteSchema } from './remoteSchema'
|
||||
import { remoteSchemaExecutor } from './remoteSchemaExecutor'
|
||||
|
||||
const debug = debugLib('cypress:graphql:remoteSchemaWrapped')
|
||||
|
||||
export interface RemoteExecutionRoot {
|
||||
requestPolicy?: RequestPolicy
|
||||
}
|
||||
|
||||
// Takes the remote schema & wraps with an "executor", allowing us to delegate
|
||||
// queries we know should be executed against this server
|
||||
export const remoteSchemaWrapped = wrapSchema({
|
||||
export const remoteSchemaWrapped = wrapSchema<DataContext>({
|
||||
schema: remoteSchema,
|
||||
executor: remoteSchemaExecutor,
|
||||
// Needed to ensure the operationName is created / propagated correctly
|
||||
createProxyingResolver ({
|
||||
createProxyingResolver: ({
|
||||
subschemaConfig,
|
||||
operation,
|
||||
transformedSchema,
|
||||
}) {
|
||||
return function proxyingResolver (_parent, _args, context, info) {
|
||||
}) => {
|
||||
return (source, args, context, info) => {
|
||||
return delegateToSchema({
|
||||
rootValue: source,
|
||||
schema: subschemaConfig,
|
||||
operation,
|
||||
transformedSchema,
|
||||
context,
|
||||
info,
|
||||
operationName: getOperationName(info),
|
||||
transformedSchema,
|
||||
rootValue: _parent,
|
||||
})
|
||||
}
|
||||
},
|
||||
executor: (obj) => {
|
||||
const info = obj.info
|
||||
|
||||
assert(obj.context?.cloud, 'Cannot execute without a DataContext')
|
||||
assert(info, 'Cannot execute without GraphQLResolveInfo')
|
||||
|
||||
const operationName = obj.context.cloud.makeOperationName(info)
|
||||
const requestPolicy = ((obj.rootValue ?? {}) as RemoteExecutionRoot).requestPolicy ?? 'cache-first'
|
||||
|
||||
debug('executing: %j', { rootValue: obj.rootValue, operationName, requestPolicy })
|
||||
|
||||
return obj.context.cloud.executeRemoteGraphQL({
|
||||
requestPolicy,
|
||||
operationType: obj.operationType ?? 'query',
|
||||
document: visit(obj.document, {
|
||||
OperationDefinition (node) {
|
||||
if (!node.name) {
|
||||
return {
|
||||
...node, name: { kind: 'Name', value: operationName },
|
||||
} as OperationDefinitionNode
|
||||
}
|
||||
|
||||
return BREAK
|
||||
},
|
||||
}),
|
||||
variables: obj.variables,
|
||||
// When we respond eagerly with a result, but receive an updated value
|
||||
// for the query, we can "push" the data down using the pushFragment subscription
|
||||
onUpdatedResult (result) {
|
||||
obj.context?.graphql.pushResult({
|
||||
result: result[info.fieldName] ?? null,
|
||||
source: obj.rootValue,
|
||||
info,
|
||||
ctx: obj.context,
|
||||
})
|
||||
},
|
||||
}) as any
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Gives a descriptive GraphQL Operation Name to any queries going out to
|
||||
* the external schema
|
||||
*/
|
||||
function getOperationName (info: GraphQLResolveInfo) {
|
||||
if (info.operation.name?.value.endsWith('_batched')) {
|
||||
return info.operation.name?.value
|
||||
}
|
||||
|
||||
return `${info.operation.name?.value ?? 'Anonymous'}_${pathToArray(info.path).join('_')}`
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ import type {
|
||||
QueryCloudProjectsBySlugsArgs,
|
||||
CloudProjectRunsArgs,
|
||||
CloudRunStatus,
|
||||
} from '../generated/test-cloud-graphql-types.gen'
|
||||
} from '../src/gen/test-cloud-graphql-types.gen'
|
||||
import type { GraphQLResolveInfo } from 'graphql'
|
||||
|
||||
type ConfigFor<T> = Omit<T, 'id' | '__typename'>
|
||||
type ConfigFor<T> = Omit<T, 'id' | '__typename'>
|
||||
|
||||
export type CloudTypesWithId = {
|
||||
[K in keyof CodegenTypeMap]: 'id' extends keyof CodegenTypeMap[K] ? K : never
|
||||
@@ -95,6 +95,19 @@ export function createCloudProject (config: Partial<ConfigFor<CloudProject>>) {
|
||||
recordKeys: [CloudRecordKeyStubs.componentProject],
|
||||
latestRun: CloudRunStubs.running,
|
||||
runs (args: CloudProjectRunsArgs) {
|
||||
if (args.before) {
|
||||
return {
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
nodes: [
|
||||
createCloudRun({ status: 'RUNNING' }),
|
||||
createCloudRun({ status: 'RUNNING' }),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const twentyRuns = _.times(20, (i) => {
|
||||
const statusIndex = i % STATUS_ARRAY.length
|
||||
const status = STATUS_ARRAY[statusIndex]
|
||||
@@ -161,10 +174,13 @@ export function createCloudRun (config: Partial<CloudRun>): Required<CloudRun> {
|
||||
totalRunning: 0,
|
||||
totalTests: 10,
|
||||
totalPassed: 10,
|
||||
commitInfo: null,
|
||||
totalDuration: 300,
|
||||
url: 'http://dummy.cypress.io/runs/1',
|
||||
createdAt: new Date('1995-12-17T03:17:00').toISOString(),
|
||||
commitInfo: createCloudRunCommitInfo({
|
||||
sha: `fake-sha-${getNodeIdx('CloudRun')}`,
|
||||
summary: `fix: make gql work ${config.status ?? 'PASSED'}`,
|
||||
}),
|
||||
...config,
|
||||
}
|
||||
|
||||
@@ -264,17 +280,22 @@ interface CloudTypesContext {
|
||||
__server__?: NexusGen['context']
|
||||
}
|
||||
|
||||
type MaybeResolver<T> = {
|
||||
[K in keyof T]: K extends 'id' | '__typename' ? T[K] : T[K] | ((args: any, ctx: CloudTypesContext, info: GraphQLResolveInfo) => MaybeResolver<T[K]>)
|
||||
}
|
||||
type MaybeResolver<T> = {
|
||||
[K in keyof T]: K extends 'id' | '__typename' ? T[K] : T[K] | ((args: any, ctx: CloudTypesContext, info: GraphQLResolveInfo) => MaybeResolver<T[K]>)
|
||||
}
|
||||
|
||||
export const CloudRunQuery: MaybeResolver<Query> = {
|
||||
export const CloudQuery: MaybeResolver<Query> = {
|
||||
__typename: 'Query',
|
||||
cloudNode (args: QueryCloudNodeArgs) {
|
||||
return nodeRegistry[args.id] ?? null
|
||||
},
|
||||
cloudProjectBySlug (args: QueryCloudProjectBySlugArgs) {
|
||||
return CloudProjectStubs.componentProject
|
||||
return projectsBySlug[args.slug] ?? createCloudProject({
|
||||
slug: args.slug,
|
||||
name: `cloud-project-${args.slug}`,
|
||||
cloudProjectSettingsUrl: 'http:/test.cloud/cloud-project/settings',
|
||||
cloudProjectUrl: 'http:/test.cloud/cloud-project/settings',
|
||||
})
|
||||
},
|
||||
cloudProjectsBySlugs (args: QueryCloudProjectsBySlugsArgs) {
|
||||
return args.slugs.map((s) => {
|
||||
@@ -297,4 +318,7 @@ export const CloudRunQuery: MaybeResolver<Query> = {
|
||||
|
||||
return CloudUserStubs.me
|
||||
},
|
||||
cloudNodesByIds ({ ids }) {
|
||||
return ids.map((id) => nodeRegistry[id] ?? null)
|
||||
},
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import * as savedState from './saved_state'
|
||||
import appData from './util/app_data'
|
||||
import browsers from './browsers'
|
||||
import devServer from './plugins/dev-server'
|
||||
import { remoteSchemaWrapped } from '@packages/graphql'
|
||||
|
||||
const { getBrowsers, ensureAndGetByNameOrPath } = browserUtils
|
||||
|
||||
@@ -41,6 +42,7 @@ export { getCtx, setCtx, clearCtx }
|
||||
export function makeDataContext (options: MakeDataContextOptions): DataContext {
|
||||
const ctx = new DataContext({
|
||||
schema: graphqlSchema,
|
||||
schemaCloud: remoteSchemaWrapped,
|
||||
...options,
|
||||
browserApi: {
|
||||
close: browsers.close,
|
||||
|
||||
30
patches/@urql+exchange-graphcache+4.3.6.patch
Normal file
30
patches/@urql+exchange-graphcache+4.3.6.patch
Normal file
@@ -0,0 +1,30 @@
|
||||
diff --git a/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.js b/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.js
|
||||
index 3148bdf..45eea4c 100644
|
||||
--- a/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.js
|
||||
+++ b/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.js
|
||||
@@ -158,6 +158,10 @@ exports.relayPagination = function relayPagination(r) {
|
||||
if (null === N) {
|
||||
continue;
|
||||
}
|
||||
+ // https://github.com/FormidableLabs/urql/issues/2430
|
||||
+ if (!N.nodes.length && !N.edges.length && l) {
|
||||
+ continue;
|
||||
+ }
|
||||
if ("inwards" === n && "number" == typeof _.last && "number" == typeof _.first) {
|
||||
var C = N.edges.slice(0, _.first + 1);
|
||||
var b = N.edges.slice(-_.last);
|
||||
diff --git a/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.mjs b/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.mjs
|
||||
index 61d3c2d..452a115 100644
|
||||
--- a/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.mjs
|
||||
+++ b/node_modules/@urql/exchange-graphcache/dist/urql-exchange-graphcache-extras.mjs
|
||||
@@ -158,6 +158,10 @@ function relayPagination(r) {
|
||||
if (null === N) {
|
||||
continue;
|
||||
}
|
||||
+ // https://github.com/FormidableLabs/urql/issues/2430
|
||||
+ if (!N.nodes.length && !N.edges.length && l) {
|
||||
+ continue;
|
||||
+ }
|
||||
if ("inwards" === n && "number" == typeof _.last && "number" == typeof _.first) {
|
||||
var C = N.edges.slice(0, _.first + 1);
|
||||
var I = N.edges.slice(-_.last);
|
||||
@@ -110,9 +110,10 @@ export async function generateFrontendSchema () {
|
||||
const testExtensions = generateTestExtensions(schema)
|
||||
const extendedSchema = extendSchema(schema, parse(testExtensions))
|
||||
|
||||
const URQL_INTROSPECTION_PATH = path.join(monorepoPaths.pkgFrontendShared, 'src/generated/urql-introspection.gen.ts')
|
||||
const URQL_INTROSPECTION_PATH = path.join(monorepoPaths.pkgDataContext, 'src/gen/urql-introspection.gen.ts')
|
||||
|
||||
await fs.ensureDir(path.dirname(URQL_INTROSPECTION_PATH))
|
||||
await fs.ensureDir(path.join(monorepoPaths.pkgFrontendShared, 'src/generated'))
|
||||
await fs.writeFile(path.join(monorepoPaths.pkgFrontendShared, 'src/generated/schema-for-tests.gen.json'), JSON.stringify(introspectionFromSchema(extendedSchema), null, 2))
|
||||
|
||||
await fs.promises.writeFile(
|
||||
|
||||
50
yarn.lock
50
yarn.lock
@@ -3711,16 +3711,6 @@
|
||||
sync-fetch "0.3.0"
|
||||
tslib "~2.3.0"
|
||||
|
||||
"@graphql-tools/batch-delegate@8.1.0":
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/batch-delegate/-/batch-delegate-8.1.0.tgz#3214703a43084c6fc8ee6c949ce181fd8cee117a"
|
||||
integrity sha512-iebquVJEjfP6QGUiQR5V1xdnB+rJbfIHYHBHQc5X/W6wUD4NlaovjGXTDHiEl/B72TWVRtpuOs4KO0WT+GKO4Q==
|
||||
dependencies:
|
||||
"@graphql-tools/delegate" "^8.2.0"
|
||||
"@graphql-tools/utils" "^8.2.0"
|
||||
dataloader "2.0.0"
|
||||
tslib "~2.3.0"
|
||||
|
||||
"@graphql-tools/batch-execute@^7.1.2":
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/batch-execute/-/batch-execute-7.1.2.tgz#35ba09a1e0f80f34f1ce111d23c40f039d4403a0"
|
||||
@@ -3731,15 +3721,15 @@
|
||||
tslib "~2.2.0"
|
||||
value-or-promise "1.0.6"
|
||||
|
||||
"@graphql-tools/batch-execute@^8.1.0":
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/batch-execute/-/batch-execute-8.1.0.tgz#fd463bab0e870a662bb00f12d5ce0013b11ae990"
|
||||
integrity sha512-PPf8SZto4elBtkaV65RldkjvxCuwYV7tLYKH+w6QnsxogfjrtiwijmewtqIlfnpPRnuhmMzmOlhoDyf0I8EwHw==
|
||||
"@graphql-tools/batch-execute@^8.1.0", "@graphql-tools/batch-execute@^8.4.6":
|
||||
version "8.4.6"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/batch-execute/-/batch-execute-8.4.6.tgz#6033cbf0b7d30c901ae4a1a7de7501aedf5a6a10"
|
||||
integrity sha512-8O42fReZMssrA4HCkpK68RlRQz/QAvLfOkz+/6dDX2X7VgZtRx3VvFiJd2hFaGdNbLzklBWXF9E6hJdJGkEO5g==
|
||||
dependencies:
|
||||
"@graphql-tools/utils" "^8.2.0"
|
||||
dataloader "2.0.0"
|
||||
"@graphql-tools/utils" "8.6.9"
|
||||
dataloader "2.1.0"
|
||||
tslib "~2.3.0"
|
||||
value-or-promise "1.0.10"
|
||||
value-or-promise "1.0.11"
|
||||
|
||||
"@graphql-tools/code-file-loader@^7.0.6":
|
||||
version "7.1.0"
|
||||
@@ -4044,6 +4034,13 @@
|
||||
dependencies:
|
||||
tslib "~2.3.0"
|
||||
|
||||
"@graphql-tools/utils@8.6.9", "@graphql-tools/utils@^8.0.1", "@graphql-tools/utils@^8.1.1", "@graphql-tools/utils@^8.2.0", "@graphql-tools/utils@^8.2.2", "@graphql-tools/utils@^8.2.3", "@graphql-tools/utils@^8.3.0", "@graphql-tools/utils@^8.5.2":
|
||||
version "8.6.9"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.6.9.tgz#fe1b81df29c9418b41b7a1ffe731710b93d3a1fe"
|
||||
integrity sha512-Z1X4d4GCT81+8CSt6SgU4t1w1UAUsAIRb67mI90k/zAs+ArkB95iE3bWXuJCUmd1+r8DGGtmUNOArtd6wkt+OQ==
|
||||
dependencies:
|
||||
tslib "~2.3.0"
|
||||
|
||||
"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.2", "@graphql-tools/utils@^7.5.0", "@graphql-tools/utils@^7.7.0", "@graphql-tools/utils@^7.7.1", "@graphql-tools/utils@^7.8.1", "@graphql-tools/utils@^7.9.0":
|
||||
version "7.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.10.0.tgz#07a4cb5d1bec1ff1dc1d47a935919ee6abd38699"
|
||||
@@ -4053,13 +4050,6 @@
|
||||
camel-case "4.1.2"
|
||||
tslib "~2.2.0"
|
||||
|
||||
"@graphql-tools/utils@^8.0.1", "@graphql-tools/utils@^8.1.1", "@graphql-tools/utils@^8.2.0", "@graphql-tools/utils@^8.2.2", "@graphql-tools/utils@^8.2.3", "@graphql-tools/utils@^8.3.0", "@graphql-tools/utils@^8.5.2":
|
||||
version "8.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.6.1.tgz#52c7eb108f2ca2fd01bdba8eef85077ead1bf882"
|
||||
integrity sha512-uxcfHCocp4ENoIiovPxUWZEHOnbXqj3ekWc0rm7fUhW93a1xheARNHcNKhwMTR+UKXVJbTFQdGI1Rl5XdyvDBg==
|
||||
dependencies:
|
||||
tslib "~2.3.0"
|
||||
|
||||
"@graphql-tools/wrap@8.1.1", "@graphql-tools/wrap@^8.1.0":
|
||||
version "8.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-8.1.1.tgz#7003033372d6ef984065028430429655614af899"
|
||||
@@ -15043,11 +15033,16 @@ data-urls@^1.0.0, data-urls@^1.1.0:
|
||||
whatwg-mimetype "^2.2.0"
|
||||
whatwg-url "^7.0.0"
|
||||
|
||||
dataloader@2.0.0, dataloader@^2.0.0:
|
||||
dataloader@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f"
|
||||
integrity sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ==
|
||||
|
||||
dataloader@2.1.0, dataloader@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.1.0.tgz#c69c538235e85e7ac6c6c444bae8ecabf5de9df7"
|
||||
integrity sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==
|
||||
|
||||
date-fns@^1.27.2:
|
||||
version "1.30.1"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
|
||||
@@ -37127,6 +37122,11 @@ value-or-promise@1.0.10:
|
||||
resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.10.tgz#5bf041f1e9a8e7043911875547636768a836e446"
|
||||
integrity sha512-1OwTzvcfXkAfabk60UVr5NdjtjJ0Fg0T5+B1bhxtrOEwSH2fe8y4DnLgoksfCyd8yZCOQQHB0qLMQnwgCjbXLQ==
|
||||
|
||||
value-or-promise@1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.11.tgz#3e90299af31dd014fe843fe309cefa7c1d94b140"
|
||||
integrity sha512-41BrgH+dIbCFXClcSapVs5M6GkENd3gQOJpEfPDNa71LsUGMXDL0jMWpI/Rh7WhX+Aalfz2TTS3Zt5pUsbnhLg==
|
||||
|
||||
value-or-promise@1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.6.tgz#218aa4794aa2ee24dcf48a29aba4413ed584747f"
|
||||
|
||||
Reference in New Issue
Block a user