Skip to content
Merged
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
19 changes: 10 additions & 9 deletions apps/vscode/src/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: "${TAG}"`,

// 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,
const UNCHECKED_VERDICTS: Record<UncheckedReason, ReferenceVerdict> = {
'no-tag': { kind: 'unverifiable', reason: 'no-tag' },
'no-registry': { kind: 'unverifiable', reason: 'no-registry' },
'needs-login': { kind: 'unverifiable', reason: 'needs-login', registry: REPOSITORY },
'authentication-failure': { kind: 'unverifiable', reason: 'authentication-failure' },
'network-error': { kind: 'unverifiable', reason: 'network-error' },
'unexpected-response': { kind: 'unverifiable', reason: 'unexpected-response' },
'malformed-reference': { kind: 'unverifiable', reason: 'malformed-reference' },
};

/** The source range of `text`'s first occurrence in {@link VALUES_YAML}. */
Expand Down Expand Up @@ -75,8 +76,8 @@ describe('diagnostics', () => {
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([]);
for (const verdict of Object.values(UNCHECKED_VERDICTS)) {
expect(diagnosticsFor(document, [createCheck(verdict)])).toEqual([]);
}
});
});
62 changes: 53 additions & 9 deletions apps/vscode/src/extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as vscode from 'vscode';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeContext } from '../test/fake-context';
import { noDockerCredentials } from '../test/fake-credentials';
import { bumpVersion, createFakeDocument } from '../test/fake-document';
import { fakeFetchResponse } from '../test/fake-fetch';
import {
Expand All @@ -9,6 +11,8 @@ import {
getLastDiagnosticCollection,
getRegisteredHoverProvider,
setVisibleTextEditors,
setWarningMessageAnswer,
window,
type TextEditorStub,
} from '../test/vscode-stub';
import { activate, deactivate } from './extension';
Expand Down Expand Up @@ -66,7 +70,7 @@ describe('extension', () => {
let context: vscode.ExtensionContext;

beforeEach(() => {
context = { subscriptions: [] } as unknown as vscode.ExtensionContext;
context = createFakeContext();
});

afterEach(() => {
Expand All @@ -75,10 +79,12 @@ describe('extension', () => {
}

setVisibleTextEditors([]);
setWarningMessageAnswer(undefined);
window.showWarningMessage.mockClear();
});

it('should create an output channel and register it for disposal on activate', () => {
activate(context, { fetch: vi.fn() });
activate(context, { fetch: vi.fn(), credentials: noDockerCredentials });

expect(vscode.window.createOutputChannel).toHaveBeenCalledWith('Infra Tools');
expect(context.subscriptions.length).toBeGreaterThanOrEqual(1);
Expand All @@ -89,20 +95,20 @@ describe('extension', () => {
});

it('should register a yaml hover provider on activate', () => {
activate(context, { fetch: vi.fn() });
activate(context, { fetch: vi.fn(), credentials: noDockerCredentials });

expect(vscode.languages.registerHoverProvider).toHaveBeenCalledWith({ language: 'yaml' }, expect.anything());
});

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

expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({ after: { margin: '0 0 0 0.5em' } });
});

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 });
activate(context, { fetch, credentials: noDockerCredentials });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
const editor = await openInVisibleEditor(document);
Expand All @@ -115,7 +121,7 @@ describe('extension', () => {

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 });
activate(context, { fetch, credentials: noDockerCredentials });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
await openInVisibleEditor(document);
Expand All @@ -129,7 +135,7 @@ describe('extension', () => {

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 });
activate(context, { fetch, credentials: noDockerCredentials });

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

Expand All @@ -144,7 +150,7 @@ describe('extension', () => {

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 });
activate(context, { fetch, credentials: noDockerCredentials });

const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
const disposedEditor: TextEditorStub = {
Expand All @@ -160,9 +166,47 @@ describe('extension', () => {
expect(getLastDiagnosticCollection()?.set).toHaveBeenCalledWith(document.uri, []);
});

it('should create a status bar item on activate', () => {
activate(context, { fetch: vi.fn(), credentials: noDockerCredentials });

expect(vscode.window.createStatusBarItem).toHaveBeenCalled();
});

it('should notify, and raise no diagnostic, for a registry with no local credential', async () => {
const fetch = vi.fn();
activate(context, { fetch, credentials: noDockerCredentials });

const document = createFakeDocument(
'/repo/chart/values.yaml',
['image:', ' repository: private.example.com/svc', ' tag: 1.0.0', ''].join('\n')
);
await openInVisibleEditor(document);

// `void`-ed in the listener, so the notification is raised during the
// check but its promise is not what the listener awaits.
expect(window.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining('private.example.com'), expect.any(String), expect.any(String));

// A missing credential is a fact about this machine, not a defect in the
// file. Putting it in the Problems panel beside real errors is how a
// panel earns being ignored.
expect(getLastDiagnosticCollection()?.set).toHaveBeenCalledWith(document.uri, []);
expect(fetch).not.toHaveBeenCalled();
});

it('should notify once per registry across files, not once per file', async () => {
activate(context, { fetch: vi.fn(), credentials: noDockerCredentials });

const values = ['image:', ' repository: private.example.com/svc', ' tag: 1.0.0', ''].join('\n');

await emitDidOpenTextDocument(createFakeDocument('/repo/chart-a/values.yaml', values));
await emitDidOpenTextDocument(createFakeDocument('/repo/chart-b/values.yaml', values));

expect(window.showWarningMessage).toHaveBeenCalledTimes(1);
});

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

await emitDidOpenTextDocument(createFakeDocument('/repo/chart/deployment.yaml', VALUES_YAML));

Expand Down
22 changes: 18 additions & 4 deletions apps/vscode/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import * as vscode from 'vscode';
import type { FetchLike } from 'oci-registry';
import { localDockerCredentials, type CredentialEnvironment, type FetchLike } from 'oci-registry';
import { diagnosticsFor } from './diagnostics';
import { hoverFor } from './hover';
import { createLoginPrompts } from './login-prompts';
import { applyMarks, createMarkDecorationType } from './marks';
import { checkImageReferencesInDocument, checksAsOf, type DocumentChecks } from './reference-check';
import { checkImageReferencesInDocument, checksAsOf, registriesNeedingLogin, type DocumentChecks } from './reference-check';

const DIAGNOSTIC_COLLECTION_NAME = 'infra-tools-images';

interface ActivateDependencies {
/** Only tests override this; production activation uses the platform's `fetch`. */
readonly fetch?: FetchLike;
/** Only tests override this; production activation reads the developer's real Docker config. */
readonly credentials?: CredentialEnvironment;
}

/**
Expand All @@ -21,20 +24,26 @@ function activate(context: vscode.ExtensionContext, dependencies: ActivateDepend
channel.appendLine('Infra Tools extension activated.');
context.subscriptions.push(channel);

const fetchImpl = dependencies.fetch ?? (globalThis as unknown as { fetch: FetchLike }).fetch;
const checkDependencies = {
fetch: dependencies.fetch ?? (globalThis as unknown as { fetch: FetchLike }).fetch,
credentials: dependencies.credentials ?? localDockerCredentials,
};
const diagnostics = vscode.languages.createDiagnosticCollection(DIAGNOSTIC_COLLECTION_NAME);
context.subscriptions.push(diagnostics);

const checksByDocument = new Map<string, DocumentChecks>();
const markDecorationType = createMarkDecorationType();
context.subscriptions.push(markDecorationType);

const loginPrompts = createLoginPrompts(context.globalState);
context.subscriptions.push(loginPrompts);

context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(async (document) => {
// VS Code never awaits a listener, so anything escaping this callback
// is an unhandled rejection, and that takes the extension host down.
try {
const checked = await checkImageReferencesInDocument(document, fetchImpl);
const checked = await checkImageReferencesInDocument(document, checkDependencies);

if (checked === undefined) {
return;
Expand All @@ -43,6 +52,11 @@ function activate(context: vscode.ExtensionContext, dependencies: ActivateDepend
checksByDocument.set(document.uri.toString(), checked);
diagnostics.set(document.uri, diagnosticsFor(document, checksAsOf(checked, document)));
applyMarks(vscode.window.visibleTextEditors, checksByDocument, markDecorationType);

// Not awaited: a notification stays up until the developer answers
// it, and holding an open-document listener for that long would tie
// this file's check to a dialog about a registry.
void loginPrompts.report(registriesNeedingLogin(checked.checks));
} catch (error) {
channel.appendLine(`Checking image references in ${document.uri.toString()} failed: ${String(error)}`);
}
Expand Down
10 changes: 8 additions & 2 deletions apps/vscode/src/hover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@ const VERIFIED_VERDICT: ReferenceVerdict = { kind: 'exists', registry: 'mirror.e
const UNCHECKED_REASON_SENTENCES: Record<UncheckedReason, string> = {
'no-tag': 'the reference names no tag to check.',
'no-registry': 'the repository names no registry host.',
'missing-credential': 'the registry requires credentials this extension cannot supply yet.',
'needs-login': 'no local Docker credential for that registry. Run `docker login` against it.',
'authentication-failure': 'the registry refused the local Docker credential for it.',
'network-error': 'the registry could not be reached.',
'unexpected-response': 'the registry answered in a form this extension does not understand.',
'malformed-reference': 'the tag is not a valid OCI tag.',
};

/** The unverifiable verdict a reason produces. Only `needs-login` carries a registry, so the shape cannot be built generically. */
function unverifiableVerdict(reason: UncheckedReason): ReferenceVerdict {
return reason === 'needs-login' ? { kind: 'unverifiable', reason, registry: 'registry.example.com' } : { kind: 'unverifiable', reason };
}

/** The source range of `text`'s first occurrence in {@link VALUES_YAML}. */
function rangeOfText(text: string): SourceRange {
const start = VALUES_YAML.indexOf(text);
Expand Down Expand Up @@ -80,7 +86,7 @@ describe('hover', () => {
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);

for (const [reason, sentence] of Object.entries(UNCHECKED_REASON_SENTENCES) as [UncheckedReason, string][]) {
const hoverText = getHoverText(hoverAtText(document, [createCheck({ kind: 'unverifiable', reason })], REPOSITORY));
const hoverText = getHoverText(hoverAtText(document, [createCheck(unverifiableVerdict(reason))], REPOSITORY));

expect(hoverText).toBe(`Not verified: ${sentence}`);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/vscode/src/hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { containsOffset } from './source-range';
const UNCHECKED_REASON_TEXT: Record<UncheckedReason, string> = {
'no-tag': 'the reference names no tag to check.',
'no-registry': 'the repository names no registry host.',
'missing-credential': 'the registry requires credentials this extension cannot supply yet.',
'needs-login': 'no local Docker credential for that registry. Run `docker login` against it.',
'authentication-failure': 'the registry refused the local Docker credential for it.',
'network-error': 'the registry could not be reached.',
'unexpected-response': 'the registry answered in a form this extension does not understand.',
'malformed-reference': 'the tag is not a valid OCI tag.',
Expand Down
116 changes: 116 additions & 0 deletions apps/vscode/src/login-prompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as vscode from 'vscode';
import { afterEach, describe, expect, it } from 'vitest';
import { createFakeContext } from '../test/fake-context';
import { getLastStatusBarItem, getLastTerminal, setWarningMessageAnswer, window } from '../test/vscode-stub';
import { createLoginPrompts, DISMISSED_REGISTRIES_KEY } from './login-prompts';

const REGISTRY = 'private.example.com';
const OTHER_REGISTRY = 'other.example.com';
const LOG_IN_ACTION = 'Log in';
const DISMISS_ACTION = 'Never for this registry';

/** Every argument `showWarningMessage` was called with, flattened to the registry each notification named. */
function notifiedMessages(): string[] {
return window.showWarningMessage.mock.calls.map(([message]) => String(message));
}

describe('login-prompts', () => {
afterEach(() => {
// The stub's staged answer is module-level state. Leaving one test's
// choice in place would answer the next test's notification for it.
setWarningMessageAnswer(undefined);
window.showWarningMessage.mockClear();
});

it('should notify once per registry, however many references and files report it', async () => {
const prompts = createLoginPrompts(createFakeContext().globalState);

await prompts.report([REGISTRY, REGISTRY]);
await prompts.report([REGISTRY]);
await prompts.report([REGISTRY, OTHER_REGISTRY]);

expect(notifiedMessages()).toHaveLength(2);
expect(notifiedMessages()[0]).toContain(REGISTRY);
expect(notifiedMessages()[1]).toContain(OTHER_REGISTRY);
});

it('should offer the two actions alongside the message', async () => {
const prompts = createLoginPrompts(createFakeContext().globalState);

await prompts.report([REGISTRY]);

expect(window.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining(REGISTRY), LOG_IN_ACTION, DISMISS_ACTION);
});

it('should run the login command with the hostname already filled in', async () => {
setWarningMessageAnswer(LOG_IN_ACTION);

const prompts = createLoginPrompts(createFakeContext().globalState);

await prompts.report([REGISTRY]);

expect(getLastTerminal()?.sendText).toHaveBeenCalledWith(`docker login ${REGISTRY}`);
expect(getLastTerminal()?.show).toHaveBeenCalled();
});

it('should persist a dismissal to extension state rather than to settings, and never notify that registry again', async () => {
setWarningMessageAnswer(DISMISS_ACTION);

const context = createFakeContext();
const prompts = createLoginPrompts(context.globalState);

await prompts.report([REGISTRY]);

expect(context.globalState.get(DISMISSED_REGISTRIES_KEY)).toEqual([REGISTRY]);

window.showWarningMessage.mockClear();
await prompts.report([REGISTRY]);

expect(window.showWarningMessage).not.toHaveBeenCalled();
});

it('should never notify a registry dismissed in an earlier session, which is what surviving a window reload means', async () => {
const context = createFakeContext({ [DISMISSED_REGISTRIES_KEY]: [REGISTRY] });
const prompts = createLoginPrompts(context.globalState);

await prompts.report([REGISTRY, OTHER_REGISTRY]);

expect(notifiedMessages()).toHaveLength(1);
expect(notifiedMessages()[0]).toContain(OTHER_REGISTRY);
});

it('should count the registries needing attention in the status bar, and stay hidden until there are any', async () => {
const prompts = createLoginPrompts(createFakeContext().globalState);
const statusBarItem = getLastStatusBarItem();

expect(window.createStatusBarItem).toHaveBeenCalledWith(vscode.StatusBarAlignment.Right, expect.any(Number));
expect(statusBarItem?.visible).toBe(false);

await prompts.report([REGISTRY, OTHER_REGISTRY]);

expect(statusBarItem?.visible).toBe(true);
expect(statusBarItem?.text).toContain('2');
expect(statusBarItem?.tooltip).toContain(REGISTRY);
expect(statusBarItem?.tooltip).toContain(OTHER_REGISTRY);
});

it('should drop a dismissed registry from the count, and hide once none are left', async () => {
setWarningMessageAnswer(DISMISS_ACTION);

const prompts = createLoginPrompts(createFakeContext().globalState);
const statusBarItem = getLastStatusBarItem();

await prompts.report([REGISTRY]);

expect(statusBarItem?.visible).toBe(false);
});

it('should dispose its status bar item', () => {
const prompts = createLoginPrompts(createFakeContext().globalState);
const statusBarItem = getLastStatusBarItem();

prompts.dispose();

expect(statusBarItem?.dispose).toHaveBeenCalled();
});
});
Loading