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:
Tim Griesser
2022-05-12 09:06:12 -04:00
committed by GitHub
parent 9ab12ecfb1
commit 383bdb1d3d
60 changed files with 1840 additions and 471 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
>
<ListRowHeader
:icon="icon"
data-cy="run-card-icon"
:data-cy="`run-card-icon-${run.status}`"
>
<template #header>
{{ run.commitInfo?.summary }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] = []

View File

@@ -1,5 +1,7 @@
import type { DataContext } from './DataContext'
export { DocumentNodeBuilder } from './util/DocumentNodeBuilder'
export {
DataContext,
} from './DataContext'

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
module.exports = {
spec: 'test/unit/**/*.spec.ts',
watchFiles: ['test/**/*.ts', 'src/**/*.ts'],
}

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -111,4 +111,4 @@
]
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { makeSchema, connectionPlugin } from 'nexus'
import * as schemaTypes from './schemaTypes/'
import { nodePlugin } from './plugins/nexusNodePlugin'
import { remoteSchemaWrapped } from './stitching/remoteSchemaWrapped'
import { mutationErrorPlugin, nexusDebugLogPlugin, nexusSlowGuardPlugin, 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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);

View File

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

View File

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