@@ -1633,6 +1654,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 +1937,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 +1953,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 +2132,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 +2179,7 @@ export class ComponentBrowserProvider {
});
loading.style.display = 'none';
+ loading.classList.remove('version-error');
select.disabled = false;
currentVersions = versions;
versionsLoaded = true;
@@ -2194,28 +2234,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..313d1b5a 100644
--- a/tests/unit/completionInputContext.test.ts
+++ b/tests/unit/completionInputContext.test.ts
@@ -13,6 +13,7 @@ import * as assert from 'node:assert/strict';
import {
findCompletionInputContextAtLine,
buildInputInsertValue,
+ allowedValuesFor,
} from '../../src/providers/completionInputContext';
import type { ComponentParameter } from '../../src/types/git-component';
@@ -383,6 +384,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', () => {
@@ -391,7 +411,6 @@ suite('buildInputInsertValue', () => {
test('renders a string default bare and stringifies a non-string default', () => {
assert.strictEqual(buildInputInsertValue({ ...base, default: 'dev' }), 'dev');
assert.strictEqual(buildInputInsertValue({ ...base, type: 'number', default: 42 }), '42');
- assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: true }), 'true');
});
test('quotes a string default only when a bare scalar would not round-trip', () => {
@@ -418,9 +437,44 @@ suite('buildInputInsertValue', () => {
assert.strictEqual(buildInputInsertValue({ ...base, type: 'array', default: ['a,b', 'c'] }), '["a,b", c]');
});
- test('offers both boolean values, leading with the safer one by requiredness', () => {
+ test('offers both boolean values in conventional order when there is no default', () => {
+ // `true, false` reads the way someone scanning the dropdown expects, regardless of requiredness.
assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: true }), '${1|true,false|}');
- assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: false }), '${1|false,true|}');
+ assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', required: false }), '${1|true,false|}');
+ });
+
+ test('offers both boolean values when the input has a default, pre-selecting the default', () => {
+ // A boolean is a closed two-value enum, so a default pre-fills the choice rather than replacing it.
+ assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: false }), '${1|false,true|}');
+ assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: true }), '${1|true,false|}');
+ // A default always leads, overriding the conventional true-first order.
+ assert.strictEqual(
+ buildInputInsertValue({ ...base, type: 'boolean', required: true, default: false }),
+ '${1|false,true|}'
+ );
+ });
+
+ test('allowedValuesFor covers the value slot, where a boolean has no literal options list', () => {
+ // The value slot (cursor after `test:`) offers `allowedValuesFor`, so a boolean must yield true/false there
+ // even though the spec declares no `options:` — otherwise typing after `test:` suggests nothing.
+ assert.deepStrictEqual(allowedValuesFor({ ...base, type: 'boolean' }), [true, false]);
+ assert.deepStrictEqual(allowedValuesFor({ ...base, type: 'boolean', required: true }), [true, false]);
+ // An explicit options list is used as declared, and a free-text input offers nothing.
+ assert.deepStrictEqual(allowedValuesFor({ ...base, options: ['aws', 'gcp'] }), ['aws', 'gcp']);
+ assert.strictEqual(allowedValuesFor({ ...base, type: 'string' }), undefined);
+ });
+
+ test('offers the choice for a boolean input whose type was inferred from an untyped default', () => {
+ // A spec that declares `default: false` with no `type:` is a boolean input; the parser infers the type, so
+ // the snippet builder sees `type: 'boolean'` and offers the choice like any other boolean.
+ assert.strictEqual(buildInputInsertValue({ ...base, type: 'boolean', default: false }), '${1|false,true|}');
+ });
+
+ test('inserts a boolean default unquoted, so GitLab reads it as a boolean', () => {
+ // Regression: a stringified `"false"` default (from the line-based spec parser) inserted `p: "false"` — a
+ // string where a boolean was declared. The choice list must carry bare `false`, never `"false"`.
+ const built = buildInputInsertValue({ ...base, type: 'boolean', default: false });
+ assert.ok(!built.includes('"'), `boolean choice must not be quoted, got ${built}`);
});
test('seeds empty literals for array and object inputs', () => {
diff --git a/tests/unit/componentFetcherTemplates.test.ts b/tests/unit/componentFetcherTemplates.test.ts
index 9768040c..dc7a0e86 100644
--- a/tests/unit/componentFetcherTemplates.test.ts
+++ b/tests/unit/componentFetcherTemplates.test.ts
@@ -12,7 +12,7 @@ import * as assert from 'node:assert/strict';
import type { GitLabTreeItem } from '../../src/types/api';
import type { ComponentParameter } from '../../src/types/git-component';
import {
- backfillParameterOptions,
+ backfillParameterSpecDetail,
deriveComponentName,
filterSubdirectories,
filterYamlBlobs,
@@ -129,7 +129,7 @@ suite('deriveComponentName — deeper nesting and edge cases', () => {
});
});
-suite('backfillParameterOptions', () => {
+suite('backfillParameterSpecDetail', () => {
const param = (name: string, extra: Partial = {}): ComponentParameter => ({
name,
description: '',
@@ -142,7 +142,7 @@ suite('backfillParameterOptions', () => {
const catalog = [param('registry_type'), param('region')];
const template = [param('registry_type', { options: ['aws', 'gcp'] }), param('region')];
- const merged = backfillParameterOptions(catalog, template);
+ const merged = backfillParameterSpecDetail(catalog, template);
assert.deepStrictEqual(merged.find((p) => p.name === 'registry_type')?.options, ['aws', 'gcp']);
assert.strictEqual(merged.find((p) => p.name === 'region')?.options, undefined);
@@ -152,7 +152,7 @@ suite('backfillParameterOptions', () => {
const catalog = [param('region')];
const template = [param('registry_type', { options: ['aws'] })]; // different name
- const merged = backfillParameterOptions(catalog, template);
+ const merged = backfillParameterSpecDetail(catalog, template);
assert.strictEqual(merged[0].options, undefined);
});
@@ -161,7 +161,7 @@ suite('backfillParameterOptions', () => {
const catalog = [param('a'), param('b')];
const template = [param('a', { options: [] }), param('b')]; // empty + missing
- const merged = backfillParameterOptions(catalog, template);
+ const merged = backfillParameterSpecDetail(catalog, template);
assert.strictEqual(merged.find((p) => p.name === 'a')?.options, undefined);
assert.strictEqual(merged.find((p) => p.name === 'b')?.options, undefined);
@@ -172,8 +172,62 @@ suite('backfillParameterOptions', () => {
const catalog = [catalogParam];
const template = [param('registry_type', { options: ['aws'] })];
- backfillParameterOptions(catalog, template);
+ backfillParameterSpecDetail(catalog, template);
assert.strictEqual(catalogParam.options, undefined, 'original catalog param was mutated');
});
+
+ test('recovers a boolean type the catalog reported as an untyped string', () => {
+ // The catalog can describe a boolean input without a type, leaving the 'string' fallback in place. Completion
+ // keys off `type`, so without this the input gets no true/false choice.
+ const catalog = [param('debug', { default: 'false' })];
+ const template = [param('debug', { type: 'boolean', default: false })];
+
+ const merged = backfillParameterSpecDetail(catalog, template);
+
+ assert.strictEqual(merged[0].type, 'boolean');
+ assert.strictEqual(merged[0].default, false, 'a stringified catalog default should yield to the parsed one');
+ });
+
+ test('the local parse wins on the type signature, since both sides describe the same spec', () => {
+ // The catalog reports what GitLab read from this same template, only lossily — so on `type`/`default`/`options`
+ // the local parse is preferred rather than arbitrated against.
+ const catalog = [param('env', { type: 'string', default: 'production' })];
+ const template = [param('env', { type: 'string', default: 'staging' })];
+
+ const merged = backfillParameterSpecDetail(catalog, template);
+
+ assert.strictEqual(merged[0].default, 'staging');
+ });
+
+ test('does not downgrade a catalog type to the template parse fallback', () => {
+ // Both sides fall back to 'string', so a template 'string' may just mean the line-based parse missed the
+ // `type:` line. Overwriting with it would lose a type the catalog got right.
+ const catalog = [param('count', { type: 'number' })];
+ const template = [param('count', { type: 'string' })];
+
+ const merged = backfillParameterSpecDetail(catalog, template);
+
+ assert.strictEqual(merged[0].type, 'number');
+ });
+
+ test('keeps a catalog default the template parse does not have', () => {
+ const catalog = [param('env', { default: 'production' })];
+ const template = [param('env')]; // no default parsed
+
+ const merged = backfillParameterSpecDetail(catalog, template);
+
+ assert.strictEqual(merged[0].default, 'production');
+ });
+
+ test('leaves the fields the catalog is authoritative for untouched', () => {
+ const catalog = [param('debug', { description: 'from catalog', required: true })];
+ const template = [param('debug', { description: 'from template', required: false, type: 'boolean' })];
+
+ const merged = backfillParameterSpecDetail(catalog, template);
+
+ assert.strictEqual(merged[0].description, 'from catalog');
+ assert.strictEqual(merged[0].required, true);
+ assert.strictEqual(merged[0].type, 'boolean', 'the type signature still comes from the local parse');
+ });
});
diff --git a/tests/unit/csp.test.ts b/tests/unit/csp.test.ts
new file mode 100644
index 00000000..660e0c0b
--- /dev/null
+++ b/tests/unit/csp.test.ts
@@ -0,0 +1,66 @@
+// @mocha
+/**
+ * Tests src/webview/csp.ts — the nonce and Content-Security-Policy meta tag every webview document is rendered with.
+ */
+
+import * as assert from 'node:assert/strict';
+import { createNonce, cspMetaTag } from '../../src/webview/csp';
+
+const CSP_SOURCE = 'vscode-webview://test-origin';
+
+suite('createNonce', () => {
+ test('returns 32 characters from the nonce alphabet', () => {
+ const nonce = createNonce();
+ assert.equal(nonce.length, 32);
+ assert.match(nonce, /^[A-Za-z0-9\-_]{32}$/);
+ });
+
+ test('returns a different value on each call', () => {
+ const nonces = new Set(Array.from({ length: 50 }, () => createNonce()));
+ assert.equal(nonces.size, 50);
+ });
+
+ test('draws uniformly across the alphabet', () => {
+ const counts = new Map();
+ for (let i = 0; i < 2000; i++) {
+ for (const char of createNonce()) {
+ counts.set(char, (counts.get(char) ?? 0) + 1);
+ }
+ }
+
+ const frequencies = [...counts.values()];
+ const expected = (2000 * 32) / 64;
+ assert.equal(counts.size, 64, 'every character in the alphabet should appear');
+ assert.ok(
+ Math.max(...frequencies) < expected * 1.15,
+ `no character should be over-represented: max ${Math.max(...frequencies)} vs expected ${expected}`,
+ );
+ });
+});
+
+suite('cspMetaTag', () => {
+ test('denies everything by default', () => {
+ assert.match(cspMetaTag(CSP_SOURCE, 'abc123'), /default-src 'none'/);
+ });
+
+ test('admits scripts only with the supplied nonce', () => {
+ const tag = cspMetaTag(CSP_SOURCE, 'abc123');
+ assert.match(tag, /script-src 'nonce-abc123'/);
+ assert.doesNotMatch(tag, /script-src[^;]*'unsafe-inline'/);
+ });
+
+ test('admits styles from the webview origin but not inline', () => {
+ const tag = cspMetaTag(CSP_SOURCE, 'abc123');
+ assert.match(tag, new RegExp(`style-src ${CSP_SOURCE}`));
+ // Inline styles are what the external-stylesheet pattern exists to avoid; permitting them here would silently
+ // undo it for every document built on this helper.
+ assert.doesNotMatch(tag, /style-src[^;]*'unsafe-inline'/);
+ });
+
+ test('is a well-formed meta tag carrying the webview origin', () => {
+ const tag = cspMetaTag(CSP_SOURCE, 'abc123');
+ assert.ok(tag.startsWith(''));
+ assert.ok(tag.includes(CSP_SOURCE));
+ });
+});
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', () => {