@@ -1633,6 +1607,9 @@ export class ComponentBrowserProvider {
font-size: 0.9em;
color: var(--vscode-disabledForeground);
}
+ .version-loading.version-error {
+ color: var(--vscode-errorForeground);
+ }
.parameters {
border: 1px solid var(--vscode-panel-border);
border-radius: 5px;
@@ -1913,7 +1890,9 @@ export class ComponentBrowserProvider {
console.log('Version changed to:', selectedVersion);
- // Show loading state
+ // Show loading state, clearing any error left by a previous attempt.
+ loading.textContent = 'Loading version details...';
+ loading.classList.remove('version-error');
loading.style.display = 'inline';
// Send message to fetch details for this version
@@ -1927,6 +1906,9 @@ export class ComponentBrowserProvider {
const loading = document.getElementById('versionLoading');
const select = document.getElementById('versionSelect');
+ // Clear any error left by a previous attempt before starting a new one.
+ loading.textContent = 'Loading version details...';
+ loading.classList.remove('version-error');
loading.style.display = 'inline';
select.disabled = true;
@@ -2103,19 +2085,29 @@ export class ComponentBrowserProvider {
case 'versionsLoaded':
updateVersionDropdown(message.versions, message.currentVersion, message.versionLabels);
break;
- case 'versionsError':
- document.getElementById('versionLoading').style.display = 'none';
+ case 'versionsError': {
+ // Reuse the loading slot to report the failure: a refresh that silently does nothing reads as an
+ // inert button, so the user is told rather than left guessing.
+ const versionStatus = document.getElementById('versionLoading');
+ versionStatus.textContent = 'Could not load versions: ' + (message.error || 'unknown error');
+ versionStatus.classList.add('version-error');
+ versionStatus.style.display = 'inline';
document.getElementById('versionSelect').disabled = false;
- // Could show error message here
break;
+ }
case 'componentDetailsUpdated':
updateComponentDetails(message.component);
break;
- case 'versionChangeError':
- document.getElementById('versionLoading').style.display = 'none';
- // Could show error message here
+ case 'versionChangeError': {
+ // Same treatment as versionsError: a failed version switch used to hide the spinner and say nothing,
+ // which reads as the dropdown simply not working.
+ const changeStatus = document.getElementById('versionLoading');
+ changeStatus.textContent = 'Could not load that version: ' + (message.error || 'unknown error');
+ changeStatus.classList.add('version-error');
+ changeStatus.style.display = 'inline';
console.error('Version change error:', message.error);
break;
+ }
}
});
@@ -2140,6 +2132,7 @@ export class ComponentBrowserProvider {
});
loading.style.display = 'none';
+ loading.classList.remove('version-error');
select.disabled = false;
currentVersions = versions;
versionsLoaded = true;
@@ -2194,28 +2187,8 @@ export class ComponentBrowserProvider {
return renderInlineMarkdown(value);
}
- /**
- * Source for the client-side twin of {@link renderInlineMarkdown}, injected into every webview ` & 'q'`),
+ '<script>alert("x")</script> & 'q'',
+ );
+ });
+
+ test('matches the server renderer across representative descriptions', () => {
+ const render = loadClientRenderer();
+ const samples = [
+ 'CI Job template to deploy a service to an ECS cluster',
+ 'A [GitLab CI/CD component](https://example.com/c) that lints Dockerfiles using [hadolint](https://example.com/h)',
+ 'Installs the `yu-ci-tools` binary CLI',
+ '**Bold** lead-in, *emphasis*, and a `code` span',
+ 'Ampersands &
and "quotes"',
+ '',
+ ];
+
+ for (const sample of samples) {
+ assert.equal(render(sample), renderInlineMarkdown(sample), `mismatch for: ${sample}`);
+ }
+ });
+});
diff --git a/tests/unit/completionInputContext.test.ts b/tests/unit/completionInputContext.test.ts
index e6b3d4f8..d1ca979a 100644
--- a/tests/unit/completionInputContext.test.ts
+++ b/tests/unit/completionInputContext.test.ts
@@ -383,6 +383,25 @@ include:
existingInputNames: [],
});
});
+
+ // A `!reference` anywhere in the file used to fail the parse outright, so an empty inputs slot offered nothing.
+ test('resolves the inputs slot when a later job uses a !reference tag', () => {
+ // The slot line carries the indentation the user has typed into it, hence the explicit spaces.
+ const text = `include:
+ - component: ${FULL_PIPELINE_URL}
+ inputs:
+${' '}
+test:
+ script:
+ - !reference [.pnpm-setup, script]`;
+ const ctx = findCompletionInputContextAtLine(text, 3, 6);
+ assert.deepStrictEqual(ctx, {
+ componentUrl: FULL_PIPELINE_URL,
+ includeKind: 'component',
+ slot: 'name',
+ existingInputNames: [],
+ });
+ });
});
suite('buildInputInsertValue', () => {
diff --git a/tests/unit/tagScoping.test.ts b/tests/unit/tagScoping.test.ts
index b31eac90..c463e9b9 100644
--- a/tests/unit/tagScoping.test.ts
+++ b/tests/unit/tagScoping.test.ts
@@ -9,12 +9,7 @@
*/
import * as assert from 'node:assert/strict';
-import {
- compileTagTemplate,
- scopeTagsToComponent,
- stripTagPrefix,
- DEFAULT_TAG_PATTERN,
-} from '../../src/services/component/tagScoping';
+import { DEFAULT_TAG_PATTERN, buildVersionLabels, compileTagTemplate, scopeTagsToComponent, stripTagPrefix } from '../../src/services/component/tagScoping';
import { selectDefaultVersion } from '../../src/providers/componentBrowserTransform';
// A realistic mixed tag list for a tag-per-component monorepo using the default `{name}-{version}` convention.
@@ -136,3 +131,28 @@ suite('selectDefaultVersion — monorepo', () => {
assert.strictEqual(chosen, scoped[0]);
});
});
+
+suite('buildVersionLabels', () => {
+ const versions = ['deploy-1.0.0', 'deploy-1.1.0', 'main'];
+
+ test('maps each tag to its stripped {version} for a monorepo source', () => {
+ assert.deepStrictEqual(buildVersionLabels(versions, 'deploy', '{name}-{version}'), {
+ 'deploy-1.0.0': '1.0.0',
+ 'deploy-1.1.0': '1.1.0',
+ // A tag that doesn't match the template (a branch name) keeps its full form.
+ main: 'main',
+ });
+ });
+
+ test('returns undefined with no template, so the webview falls back to the full tag', () => {
+ assert.strictEqual(buildVersionLabels(versions, 'deploy', undefined), undefined);
+ });
+
+ test('returns undefined when the template does not compile', () => {
+ assert.strictEqual(buildVersionLabels(versions, 'deploy', 'no-tokens-here'), undefined);
+ });
+
+ test('returns an empty map rather than undefined for an empty version list', () => {
+ assert.deepStrictEqual(buildVersionLabels([], 'deploy', '{name}-{version}'), {});
+ });
+});
diff --git a/tests/unit/versionLookupShape.test.ts b/tests/unit/versionLookupShape.test.ts
new file mode 100644
index 00000000..a878fc27
--- /dev/null
+++ b/tests/unit/versionLookupShape.test.ts
@@ -0,0 +1,56 @@
+// @mocha
+/**
+ * Tests src/services/component/versionLookupShape.ts — the guard deciding whether a component can be used for a
+ * version lookup. It previously required `url`, which the details panel's webview-rebuilt component never carries,
+ * so Refresh Versions failed there for every component.
+ */
+
+import * as assert from 'node:assert/strict';
+import { isVersionLookupShape } from '../../src/services/component/versionLookupShape';
+import type { Component } from '../../src/providers/componentDetector';
+
+/** The shape the details panel receives: a `ComponentVersion` plus name/version, with no `url`. */
+const detailsPanelComponent: Component = {
+ name: 'deploy',
+ description: 'Deploy the thing',
+ parameters: [],
+ source: 'Test Source',
+ sourcePath: 'group/monorepo',
+ gitlabInstance: 'gitlab.com',
+ version: 'deploy-1.0.0',
+};
+
+/** The fixture minus one field, for the "what happens when this is missing" cases. */
+function without(field: keyof Component): Component {
+ const component = { ...detailsPanelComponent };
+ delete component[field];
+ return component;
+}
+
+suite('isVersionLookupShape', () => {
+ test('accepts the details panel component, which has no url', () => {
+ assert.strictEqual('url' in detailsPanelComponent, false, 'fixture should model the missing url');
+ assert.strictEqual(isVersionLookupShape(detailsPanelComponent), true);
+ });
+
+ test('accepts a component with no source, which the lookup never reads', () => {
+ assert.strictEqual(isVersionLookupShape(without('source')), true);
+ });
+
+ test('still accepts a fully populated cache entry', () => {
+ const cached = { ...detailsPanelComponent, url: 'https://gitlab.com/group/monorepo/deploy@deploy-1.0.0' };
+ assert.strictEqual(isVersionLookupShape(cached), true);
+ });
+
+ test('rejects a component with no sourcePath', () => {
+ assert.strictEqual(isVersionLookupShape(without('sourcePath')), false);
+ });
+
+ test('rejects a component with no gitlabInstance', () => {
+ assert.strictEqual(isVersionLookupShape(without('gitlabInstance')), false);
+ });
+
+ test('rejects a component with no version', () => {
+ assert.strictEqual(isVersionLookupShape(without('version')), false);
+ });
+});
diff --git a/tests/unit/yamlParser.test.ts b/tests/unit/yamlParser.test.ts
index 0314e74d..dcde39ad 100644
--- a/tests/unit/yamlParser.test.ts
+++ b/tests/unit/yamlParser.test.ts
@@ -9,7 +9,7 @@
*/
import * as assert from 'node:assert/strict';
-import { parseYamlDocuments, findDocumentWith } from '../../src/utils/yamlParser';
+import { parseYaml, parseYamlDocuments, findDocumentWith, isYamlNode } from '../../src/utils/yamlParser';
suite('parseYamlDocuments', () => {
test('returns every mapping document of a multi-document stream', () => {
@@ -41,6 +41,135 @@ include:
test('returns [] on unparseable input', () => {
assert.deepStrictEqual(parseYamlDocuments('key: "unterminated', true), []);
});
+
+ // A stock schema throws on GitLab's `!reference`, taking the whole document — `include:` and all — down with it.
+ test('parses a document using GitLab\'s !reference tag', () => {
+ const text = `include:
+ - component: https://gitlab.com/c/x@1.0.0
+ inputs:
+ stage: build
+
+test:
+ script:
+ - !reference [.pnpm-setup, script]
+`;
+ const docs = parseYamlDocuments(text, true);
+ assert.strictEqual(docs.length, 1);
+ const doc = findDocumentWith(docs, 'include');
+ assert.ok(doc, 'the include-bearing document should survive the !reference tag');
+ assert.deepStrictEqual(doc.include, [
+ { component: 'https://gitlab.com/c/x@1.0.0', inputs: { stage: 'build' } },
+ ]);
+ });
+
+ test('constructs !reference as the path sequence it points at', () => {
+ const docs = parseYamlDocuments('test:\n script:\n - !reference [.setup, script]\n', true);
+ assert.deepStrictEqual(docs[0].test, { script: [['.setup', 'script']] });
+ });
+
+ // GitLab parses with Psych, where `<<: *anchor` merges. Left unmerged, an input inheriting its `default` through
+ // an anchor reads as required, and a merged `spec.inputs` offers an input named `<<`.
+ test('merges `<<:` into the surrounding mapping', () => {
+ const text = `.defaults: &defaults
+ stage:
+ type: string
+ default: build
+spec:
+ inputs:
+ <<: *defaults
+ extra:
+ type: string
+`;
+ const docs = parseYamlDocuments(text, true);
+ assert.deepStrictEqual(findDocumentWith(docs, 'spec')?.spec, {
+ inputs: {
+ stage: { type: 'string', default: 'build' },
+ extra: { type: 'string' },
+ },
+ });
+ });
+
+ test('merges a sequence of anchors, earlier entries winning', () => {
+ const text = `.a: &a
+ x: 1
+ y: one
+.b: &b
+ y: two
+ z: 3
+job:
+ <<: [*a, *b]
+`;
+ const docs = parseYamlDocuments(text, true);
+ assert.deepStrictEqual(docs[0].job, { x: 1, y: 'one', z: 3 });
+ });
+
+ // YAML 1.1 scalar resolution would make these booleans; all are plausible job or input names.
+ test('keeps `y`, `n`, `yes`, `no`, `on`, `off` as string keys', () => {
+ const text = 'spec:\n inputs:\n y: 1\n n: 2\n yes: 3\n no: 4\n on: 5\n off: 6\n';
+ const spec = findDocumentWith(parseYamlDocuments(text, true), 'spec')?.spec;
+ assert.ok(isYamlNode(spec));
+ assert.ok(isYamlNode(spec.inputs));
+ assert.deepStrictEqual(Object.keys(spec.inputs), ['y', 'n', 'yes', 'no', 'on', 'off']);
+ });
+
+ // Any local tag is fatal to a stock parse, not just a sequence-position `!reference`. Each of these forms took the
+ // whole document down while only the sequence form was handled, so the tags match by prefix on `!` instead.
+ test('tolerates a local tag in every node position', () => {
+ const cases: [string, string, unknown][] = [
+ ['scalar', 'key: !reference foo', { key: 'foo' }],
+ ['sequence', 'key: !reference [.setup, script]', { key: ['.setup', 'script'] }],
+ ['mapping', 'key: !reference\n nested: value', { key: { nested: 'value' } }],
+ ];
+ for (const [position, text, expected] of cases) {
+ assert.deepStrictEqual(parseYamlDocuments(text, true)[0], expected, `${position} position`);
+ }
+ });
+
+ // The shape a user is mid-way through typing: `!reference` with no argument yet. Losing the parse here blanks
+ // completion at exactly the moment it is wanted.
+ test('tolerates a half-typed tag with no value yet', () => {
+ const text = `include:
+ - component: https://gitlab.com/c/x@1.0.0
+ inputs:
+ stage: build
+
+test:
+ script:
+ - !reference
+`;
+ const doc = findDocumentWith(parseYamlDocuments(text, true), 'include');
+ assert.ok(doc, 'the include must still resolve while a tag is half-typed');
+ });
+
+ test('tolerates an unknown tag that is not !reference', () => {
+ assert.deepStrictEqual(parseYamlDocuments('a: !custom [1, 2]', true)[0], { a: [1, 2] });
+ });
+
+ // The tolerated tags must not disturb ordinary YAML: core scalars keep their types rather than becoming strings.
+ test('leaves untagged YAML and its scalar types alone', () => {
+ const text = 'num: 1\nbool: true\nnul: null\nstr: plain\nlist:\n - a\n';
+ assert.deepStrictEqual(parseYamlDocuments(text, true)[0], {
+ num: 1,
+ bool: true,
+ nul: null,
+ str: 'plain',
+ list: ['a'],
+ });
+ });
+});
+
+// `parseYaml` is the single-document path (the completion round-trip probe, the component browser's wrapped-include
+// parse). It takes the same schema, but the tests above all go through `parseYamlDocuments`.
+suite('parseYaml', () => {
+ test('tolerates a local tag in every node position', () => {
+ assert.deepStrictEqual(parseYaml('key: !reference foo', true), { key: 'foo' });
+ assert.deepStrictEqual(parseYaml('key: !reference [.setup, script]', true), { key: ['.setup', 'script'] });
+ assert.deepStrictEqual(parseYaml('key: !reference\n nested: value', true), { key: { nested: 'value' } });
+ });
+
+ test('still returns null on genuinely malformed YAML', () => {
+ assert.strictEqual(parseYaml('key: "unterminated', true), null);
+ });
});
suite('findDocumentWith', () => {