Skip to content
82 changes: 82 additions & 0 deletions apps/vscode/src/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as vscode from 'vscode';
import { describe, expect, it } from 'vitest';
import type { SourceRange } from 'helm';
import { createFakeDocument } from '../test/fake-document';
import { diagnosticsFor } from './diagnostics';
import type { ReferenceCheck, ReferenceVerdict, UncheckedReason } from './reference-check';

const REPOSITORY = 'registry.example.com/svc';
const TAG = '1.0';
const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: "${TAG}"`, ''].join('\n');

// A record rather than a list, so a new reason fails the build here instead
// of going untested.
const UNCHECKED_REASONS: Record<UncheckedReason, true> = {
'no-tag': true,
'no-registry': true,
'missing-credential': true,
'network-error': true,
'unexpected-response': true,
'malformed-reference': true,
};

/** The source range of `text`'s first occurrence in {@link VALUES_YAML}. */
function rangeOfText(text: string): SourceRange {
const start = VALUES_YAML.indexOf(text);

return { start, end: start + text.length };
}

/** The document range covering `text`'s first occurrence in {@link VALUES_YAML}. */
function documentRangeOfText(document: vscode.TextDocument, text: string): vscode.Range {
const { start, end } = rangeOfText(text);

return new vscode.Range(document.positionAt(start), document.positionAt(end));
}

/** A check over the single reference in {@link VALUES_YAML}, carrying that file's real offsets. */
function createCheck(verdict: ReferenceVerdict): ReferenceCheck {
return {
reference: {
repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) },
tag: { text: TAG, range: rangeOfText(TAG) },
},
verdict,
};
}

describe('diagnostics', () => {
it('should report an error naming the missing repository, positioned on the repository value', () => {
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
const fileDiagnostics = diagnosticsFor(document, [createCheck({ kind: 'repository-not-found', repository: REPOSITORY })]);

expect(fileDiagnostics).toHaveLength(1);
expect(fileDiagnostics[0]?.severity).toBe(vscode.DiagnosticSeverity.Error);
expect(fileDiagnostics[0]?.message).toContain(REPOSITORY);
expect(fileDiagnostics[0]?.range).toEqual(documentRangeOfText(document, REPOSITORY));
});

it('should report an error naming the missing tag, positioned on the tag value', () => {
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
const fileDiagnostics = diagnosticsFor(document, [createCheck({ kind: 'tag-not-found', repository: REPOSITORY, tag: TAG })]);

expect(fileDiagnostics).toHaveLength(1);
expect(fileDiagnostics[0]?.severity).toBe(vscode.DiagnosticSeverity.Error);
expect(fileDiagnostics[0]?.message).toContain(TAG);
expect(fileDiagnostics[0]?.range).toEqual(documentRangeOfText(document, TAG));
});

it('should report nothing for a reference that exists', () => {
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);

expect(diagnosticsFor(document, [createCheck({ kind: 'exists', registry: 'registry.example.com' })])).toEqual([]);
});

it('should report nothing for any unverifiable reason, since an unreachable registry is not a missing image', () => {
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);

for (const reason of Object.keys(UNCHECKED_REASONS) as UncheckedReason[]) {
expect(diagnosticsFor(document, [createCheck({ kind: 'unverifiable', reason })])).toEqual([]);
}
});
});
39 changes: 39 additions & 0 deletions apps/vscode/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as vscode from 'vscode';
import type { ReferenceCheck } from './reference-check';
import { rangeOf } from './source-range';

/**
* The diagnostics a document's checks call for. Only the two not-found
* verdicts qualify: that an unverifiable verdict never renders as an error
* is the one invariant this feature must not break, because an expired token
* or an unreachable registry must never look like a missing image.
*/
function diagnosticsFor(document: vscode.TextDocument, checks: readonly ReferenceCheck[]): vscode.Diagnostic[] {
const fileDiagnostics: vscode.Diagnostic[] = [];

for (const { reference, verdict } of checks) {
if (verdict.kind === 'repository-not-found') {
fileDiagnostics.push(
new vscode.Diagnostic(
rangeOf(document, reference.repository.range),
`Repository '${verdict.repository}' not found.`,
vscode.DiagnosticSeverity.Error
)
);
} else if (verdict.kind === 'tag-not-found') {
fileDiagnostics.push(
new vscode.Diagnostic(
// Unreachable fallback: only a reference that named a tag can come
// back tag-not-found. It keeps the type honest without an assertion.
rangeOf(document, reference.tag?.range ?? reference.repository.range),
`Tag '${verdict.tag}' not found in '${verdict.repository}'.`,
vscode.DiagnosticSeverity.Error
)
);
}
}

return fileDiagnostics;
}

export { diagnosticsFor };
145 changes: 79 additions & 66 deletions apps/vscode/src/extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
import * as vscode from 'vscode';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { emitDidOpenTextDocument, getLastDiagnosticCollection } from '../test/vscode-stub';
import { bumpVersion, createFakeDocument } from '../test/fake-document';
import { fakeFetchResponse } from '../test/fake-fetch';
import {
createTextEditorStub,
emitDidChangeVisibleTextEditors,
emitDidOpenTextDocument,
getLastDiagnosticCollection,
getRegisteredHoverProvider,
setVisibleTextEditors,
type TextEditorStub,
} from '../test/vscode-stub';
import { activate, deactivate } from './extension';

/** Builds a fake `vscode.TextDocument`, with a real `positionAt` so range assertions are exact. */
function createFakeDocument(path: string, text: string, languageId = 'yaml'): vscode.TextDocument {
return {
uri: { path, toString: () => path },
languageId,
getText: () => text,
positionAt: (offset: number) => {
const before = text.slice(0, offset);
const lines = before.split('\n');
const line = lines.length - 1;
const character = lines[lines.length - 1]?.length ?? 0;

return new vscode.Position(line, character);
},
} as unknown as vscode.TextDocument;
const REPOSITORY = 'docker.io/library/nginx';
const TAG = '1.19';
const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: ${TAG}`, ''].join('\n');

/** Stages `document` as the only visible editor, then fires the open event. */
async function openInVisibleEditor(document: vscode.TextDocument): Promise<TextEditorStub> {
const editor = createTextEditorStub(document);

setVisibleTextEditors([editor]);
await emitDidOpenTextDocument(document);

return editor;
}

const VALUES_YAML = ['image:', ' repository: docker.io/library/nginx', ' tag: 1.19', ''].join('\n');
/** The decoration options an editor's most recent `setDecorations` call carried. */
function getLastDecorations(editor: TextEditorStub): vscode.DecorationOptions[] {
const { calls } = editor.setDecorations.mock;
const lastCall = calls[calls.length - 1] as [unknown, vscode.DecorationOptions[]] | undefined;

if (lastCall === undefined) {
throw new Error('expected setDecorations to have been called');
}

return lastCall[1];
}

/** A canned fetch `Response`-shaped object for the injected fetch fake. */
function fakeFetchResponse(status: number, body: unknown = {}): { status: number; ok: boolean; json: () => Promise<unknown> } {
return {
status,
ok: status >= 200 && status < 300,
// eslint-disable-next-line @typescript-eslint/promise-function-async -- trivial canned response, nothing to await
json: () => Promise.resolve(body),
};
/** Asks the registered hover provider for a hover at `offset` in `document`. */
function hoverAt(document: vscode.TextDocument, offset: number): vscode.Hover | undefined {
return getRegisteredHoverProvider()?.provideHover(document, document.positionAt(offset)) as vscode.Hover | undefined;
}

/** Asserts a diagnostics `.set()` call carried exactly one diagnostic, and returns it. */
Expand Down Expand Up @@ -61,6 +73,8 @@ describe('extension', () => {
for (const subscription of context.subscriptions) {
subscription.dispose();
}

setVisibleTextEditors([]);
});

it('should create an output channel and register it for disposal on activate', () => {
Expand All @@ -74,84 +88,83 @@ describe('extension', () => {
expect(() => deactivate()).not.toThrow();
});

it('should set no diagnostics when the referenced image exists', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200));
activate(context, { fetch });
it('should register a yaml hover provider on activate', () => {
activate(context, { fetch: vi.fn() });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
await emitDidOpenTextDocument(document);
expect(vscode.languages.registerHoverProvider).toHaveBeenCalledWith({ language: 'yaml' }, expect.anything());
});

const collection = getLastDiagnosticCollection();
it('should create one mark decoration type on activate', () => {
activate(context, { fetch: vi.fn() });

expect(collection?.set).toHaveBeenCalledWith(document.uri, []);
expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({ after: { margin: '0 0 0 0.5em' } });
});

it('should set an error diagnostic naming the missing tag, positioned on the tag value, when the tag does not exist', async () => {
it('should set both diagnostics and marks when a values file opens', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(404, { errors: [{ code: 'MANIFEST_UNKNOWN' }] }));
activate(context, { fetch });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
await emitDidOpenTextDocument(document);

const collection = getLastDiagnosticCollection();
const diagnostic = getSingleDiagnostic(collection?.set.mock.calls[0]?.[1] as vscode.Diagnostic[] | undefined);
const editor = await openInVisibleEditor(document);
const diagnostic = getSingleDiagnostic(getLastDiagnosticCollection()?.set.mock.calls[0]?.[1] as vscode.Diagnostic[] | undefined);

expect(diagnostic.severity).toBe(vscode.DiagnosticSeverity.Error);
expect(diagnostic.message).toContain('1.19');

const tagStart = VALUES_YAML.indexOf('1.19');

expect(diagnostic.range).toEqual(new vscode.Range(document.positionAt(tagStart), document.positionAt(tagStart + '1.19'.length)));
expect(diagnostic.message).toContain(TAG);
expect(getLastDecorations(editor)[0]?.renderOptions?.after?.contentText).toBe(' ✗');
});

it('should set an error diagnostic naming the missing repository when the repository does not exist', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(404, { errors: [{ code: 'NAME_UNKNOWN' }] }));
it('should hover a checked reference, and stop once the document has been edited past the check', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200));
activate(context, { fetch });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
await emitDidOpenTextDocument(document);

const collection = getLastDiagnosticCollection();
const diagnostic = getSingleDiagnostic(collection?.set.mock.calls[0]?.[1] as vscode.Diagnostic[] | undefined);
await openInVisibleEditor(document);

expect(diagnostic.severity).toBe(vscode.DiagnosticSeverity.Error);
expect(diagnostic.message).toContain('docker.io/library/nginx');
expect(hoverAt(document, VALUES_YAML.indexOf(REPOSITORY))).toBeDefined();

const repositoryStart = VALUES_YAML.indexOf('docker.io/library/nginx');
bumpVersion(document);

expect(diagnostic.range).toEqual(
new vscode.Range(document.positionAt(repositoryStart), document.positionAt(repositoryStart + 'docker.io/library/nginx'.length))
);
expect(hoverAt(document, VALUES_YAML.indexOf(REPOSITORY))).toBeUndefined();
});

it('should set no diagnostics when the registry is unreachable, since unverifiable never renders as an error', async () => {
const fetch = vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND'));
it('should re-apply marks to an editor that becomes visible after the document was checked', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200));
activate(context, { fetch });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);

setVisibleTextEditors([]);
await emitDidOpenTextDocument(document);

const collection = getLastDiagnosticCollection();
const editor = createTextEditorStub(document);
await emitDidChangeVisibleTextEditors([editor]);

expect(collection?.set).toHaveBeenCalledWith(document.uri, []);
expect(getLastDecorations(editor)[0]?.renderOptions?.after?.contentText).toBe(' ✓');
});

it('should ignore a document that is not the conventional values file name', async () => {
const fetch = vi.fn();
it('should survive an editor disposed mid-check, since an unhandled rejection kills the extension host', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200));
activate(context, { fetch });

const document = createFakeDocument('/repo/chart/deployment.yaml', VALUES_YAML);
await emitDidOpenTextDocument(document);
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
const disposedEditor: TextEditorStub = {
document,
setDecorations: vi.fn(() => {
throw new Error('TextEditor#setDecorations: editor disposed');
}),
};

expect(fetch).not.toHaveBeenCalled();
setVisibleTextEditors([disposedEditor]);

await expect(emitDidOpenTextDocument(document)).resolves.toBeUndefined();
expect(getLastDiagnosticCollection()?.set).toHaveBeenCalledWith(document.uri, []);
});

it('should ignore a document whose language is not yaml', async () => {
it('should issue no request for a document that is not a values file', async () => {
const fetch = vi.fn();
activate(context, { fetch });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML, 'plaintext');
await emitDidOpenTextDocument(document);
await emitDidOpenTextDocument(createFakeDocument('/repo/chart/deployment.yaml', VALUES_YAML));

expect(fetch).not.toHaveBeenCalled();
});
Expand Down
Loading