Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/graphql/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ const deploymentQuery: DocumentNode = gql`
}
`;

// Legacy timestamp-paged deployment logs. Retained only as the fallback for
// regions whose logs service predates getDeploymentLogsV2 — see
// LogPolling.deploymentLogs.
const deploymentLogsQuery: DocumentNode = gql`
query GetLogs($deploymentUid: ID!, $timestamp: String) {
getLogs(deploymentUid: $deploymentUid, timestamp: $timestamp) {
Expand All @@ -139,6 +142,26 @@ const deploymentLogsQuery: DocumentNode = gql`
}
`;

// Cursor-paged deployment logs. The cursor is search_after over
// [timestampMs, _id], so paging can't skip or duplicate logs that share a
// millisecond — which timestamp paging cannot express.
const deploymentLogsV2Query: DocumentNode = gql`
query GetDeploymentLogsV2($query: DeploymentLogsV2QueryInput!) {
getDeploymentLogsV2(query: $query) {
logs {
deploymentUid
message
stage
timestamp
}
pageInfo {
hasNewer
newestCursor
}
}
}
`;

const serverlessLogsQuery: DocumentNode = gql`
query GetServerlessLogsV2($query: QueryLogMessagesV2InputType!) {
getServerlessLogsV2(query: $query) {
Expand Down Expand Up @@ -206,6 +229,7 @@ export {
cmsEnvironmentVariablesQuery,
deploymentQuery,
deploymentLogsQuery,
deploymentLogsV2Query,
serverlessLogsQuery,
latestLiveDeploymentQuery,
environmentsQuery,
Expand Down
141 changes: 136 additions & 5 deletions src/util/logs-polling-utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ import defaultConfig from '../config';
type LogPollingCtor = typeof import('./logs-polling-utilities').default;

jest.mock('@contentstack/cli-utilities', () => cliUtilitiesJestMock);
// The terminal-status branch awaits sleep(1_000) before it stops polling.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove comments

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure

jest.mock('timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));

const CONFIG = {
deployment: 'd1',
environment: 'e1',
pollingInterval: 1000,
};

function makeWatchQuery() {
let subscriber: (result: any) => void = () => {};
return {
Expand All @@ -21,11 +28,12 @@ function makeWatchQuery() {
};
}

const CONFIG = {
deployment: 'd1',
environment: 'e1',
pollingInterval: 1000,
};
function page(logs: any[], pageInfo: Record<string, unknown> = {}) {
return {
logs,
pageInfo: { hasNewer: null, newestCursor: null, ...pageInfo },
};
}

function getDeploymentStatus(LogPollingClass: LogPollingCtor, watchQuery: jest.Mock): void {
new LogPollingClass({
Expand Down Expand Up @@ -195,3 +203,126 @@ describe('cancelled deployment stops log polling', () => {
expect(defaultConfig.deploymentStatus).toContain('CANCELLED');
});
});

describe('deployment logs use cursor paging (getDeploymentLogsV2)', () => {
function buildInstance(deploymentStatus: string[] = ['DEPLOYED']) {
const statusWatchQuery = makeWatchQuery();
const logsWatchQuery = makeWatchQuery();
const fallbackWatchQuery = makeWatchQuery();
const logsClientWatchQuery = jest
.fn()
.mockReturnValueOnce(logsWatchQuery)
.mockReturnValue(fallbackWatchQuery);
const instance = new LogPolling({
apolloManageClient: { watchQuery: jest.fn().mockReturnValue(statusWatchQuery) } as any,
apolloLogsClient: { watchQuery: logsClientWatchQuery } as any,
config: { deployment: 'd1', environment: 'e1', pollingInterval: 1000, deploymentStatus } as any,
$event: new EventEmitter(),
});
return { instance, statusWatchQuery, logsWatchQuery, fallbackWatchQuery, logsClientWatchQuery };
}

it('opens with sortDirection desc and no cursor, tailing the newest page like the legacy query did', async () => {
const { instance, logsClientWatchQuery } = buildInstance();

await instance.deploymentLogs();

const { query } = logsClientWatchQuery.mock.calls[0][0].variables;
expect(query).toEqual({ deploymentUid: 'd1', limit: 5000, sortDirection: 'desc' });
expect(query).not.toHaveProperty('cursor');
});

it('advances by cursor in asc order — never by timestamp', async () => {
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);

await instance.deploymentLogs();
statusWatchQuery.emit({ data: { Deployment: { status: 'LIVE' } } });
await logsWatchQuery.emit({
data: {
getDeploymentLogsV2: page([{ message: 'build started', timestamp: '2026-08-06T10:00:00.123Z' }], {
newestCursor: '[1775462400123,"abc"]',
}),
},
});

expect(logsWatchQuery.setVariables).toHaveBeenCalledWith({
query: {
deploymentUid: 'd1',
limit: 5000,
sortDirection: 'asc',
cursor: '[1775462400123,"abc"]',
},
});
});

it('does not re-arm when the cursor has not moved, so a repeated page cannot loop forever', async () => {
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);

await instance.deploymentLogs();
statusWatchQuery.emit({ data: { Deployment: { status: 'LIVE' } } });
const samePage = {
data: {
getDeploymentLogsV2: page([{ message: 'x', timestamp: '2026-08-06T10:00:00.000Z' }], {
newestCursor: 'c1',
}),
},
};
await logsWatchQuery.emit(samePage);
await logsWatchQuery.emit(samePage);

expect(logsWatchQuery.setVariables).toHaveBeenCalledTimes(1);
});

it('keeps draining past a terminal status while hasNewer reports another page', async () => {
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);

await instance.deploymentLogs();
statusWatchQuery.emit({ data: { Deployment: { status: 'DEPLOYED' } } });
await logsWatchQuery.emit({
data: { getDeploymentLogsV2: page([{ message: 'a', timestamp: 'x' }], { hasNewer: true, newestCursor: 'c1' }) },
});

expect(logsWatchQuery.stopPolling).not.toHaveBeenCalled();

await logsWatchQuery.emit({
data: { getDeploymentLogsV2: page([{ message: 'b', timestamp: 'y' }], { hasNewer: false, newestCursor: 'c2' }) },
});

expect(logsWatchQuery.stopPolling).toHaveBeenCalledTimes(1);
});

it('falls back to the legacy getLogs query when the region has no V2 field', async () => {
const { instance, logsWatchQuery, fallbackWatchQuery, logsClientWatchQuery } = buildInstance();
const errors: any[] = [];
(instance as any).$event.on('deployment-logs', (e: any) => {
if (e.msgType === 'error') errors.push(e.message);
});

await instance.deploymentLogs();
await logsWatchQuery.emit({
data: null,
error: { message: 'Cannot query field "getDeploymentLogsV2" on type "Query".' },
});

expect(logsWatchQuery.stopPolling).toHaveBeenCalledTimes(1);
expect(logsClientWatchQuery).toHaveBeenCalledTimes(2);
expect(logsClientWatchQuery.mock.calls[1][0].variables).toEqual({ deploymentUid: 'd1' });
expect(fallbackWatchQuery.subscribe).toHaveBeenCalledTimes(1);
// The schema mismatch is not the user's problem — nothing surfaces.
expect(errors).toHaveLength(0);
});

it('does not demote to the legacy query on a transient network error', async () => {
const { instance, logsWatchQuery, logsClientWatchQuery } = buildInstance();
const errors: any[] = [];
(instance as any).$event.on('deployment-logs', (e: any) => {
if (e.msgType === 'error') errors.push(e.message);
});

await instance.deploymentLogs();
await logsWatchQuery.emit({ data: null, error: { message: 'Failed to fetch' } });

expect(logsClientWatchQuery).toHaveBeenCalledTimes(1);
expect(errors).toContain('Failed to fetch');
});
});
Loading
Loading