Skip to content

Commit 0df213a

Browse files
fix: resolve mobile and webstudio asset relations (v3.4.3)
1 parent da59814 commit 0df213a

8 files changed

Lines changed: 222 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ All notable changes to the **SFMC DevTools** VS Code extension are documented in
44

55
Publishing is triggered automatically via GitHub Actions when a new release is created.
66

7-
## [Unreleased]
7+
## [3.4.3] — 2026-09-08
8+
9+
### Fixed
10+
11+
- **JSON asset relations**: `r__asset_key` links and missing-reference checks now fall back to flat mobile metadata and flat or nested webstudio metadata in the same BU, after the existing context-specific template/message lookup.
812

913
## [3.4.2] — 2026-08-25
1014

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Example `.vscode/mcp.json`:
8080
- Deploy Marketing Cloud assets directly from the retrieve folder
8181
- Copy multiple Marketing Cloud assets from one business unit to another
8282
- Deploy multiple Marketing Cloud assets from one business unit to another
83+
- Navigate JSON `r__asset_key` references to local metadata in the same BU. References inside asset folders prefer nested template metadata; other references prefer nested message metadata. Both then try flat mobile, flat webstudio, and nested webstudio metadata, in that order. Missing-reference warnings use the same lookup order.
8384

8485
### Telemetry
8586

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "sfmc-devtools-vscode",
33
"displayName": "SFMC DevTools",
44
"description": "Unofficial IDE for Salesforce Marketing Cloud Engagement - Handle and manipulate several SFMC assets (journeys, automations, queries, SSJS, AMPScript, etc..) between your local machine and Salesforce Marketing Cloud (SFMC).",
5-
"version": "3.4.2",
5+
"version": "3.4.3",
66
"license": "MIT",
77
"publisher": "Accenture-oss",
88
"repository": {

src/editor/relatedItemDiagnosticProvider.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ class RelatedItemDiagnosticProvider {
300300

301301
/**
302302
* Checks whether the specific key file exists in the workspace.
303+
* Assets prefer nested template/message metadata according to document context,
304+
* then flat mobile, flat webstudio, and nested webstudio metadata in the same BU.
303305
* Results are cached; false is cached to avoid repeated searches.
304306
*
305307
* @param type - metadata type folder name (e.g. "dataExtension")
@@ -328,11 +330,21 @@ class RelatedItemDiagnosticProvider {
328330
let exists = false;
329331

330332
if (type === "asset") {
333+
// Match link resolution: context first, then mobile and webstudio metadata.
331334
const subtype = isInsideAssetFolder ? "template" : "message";
332-
const files = await VSCode.workspace.findFiles(
333-
`${buPrefix}/asset/${subtype}/${key}/${key}.asset-${subtype}-meta.json`
334-
);
335-
exists = files.length > 0;
335+
const paths = [
336+
`${buPrefix}/asset/${subtype}/${key}/${key}.asset-${subtype}-meta.json`,
337+
`${buPrefix}/asset/mobile/${key}.asset-mobile-meta.json`,
338+
`${buPrefix}/asset/webstudio/${key}.asset-webstudio-meta.json`,
339+
`${buPrefix}/asset/webstudio/${key}/${key}.asset-webstudio-meta.json`
340+
];
341+
for (const path of paths) {
342+
const files = await VSCode.workspace.findFiles(path);
343+
if (files.length > 0) {
344+
exists = true;
345+
break;
346+
}
347+
}
336348
} else {
337349
const files = await VSCode.workspace.findFiles(`${buPrefix}/${type}/${key}.${type}-meta.json`);
338350
if (files.length > 0) {

src/editor/relatedItemLinkProvider.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ function getLeadingValueStart(matchIndex: number, matchStr: string, fieldName: s
120120
* → asset/message/<key>/<key>.asset-message-meta.json
121121
* • r__asset_key inside an asset folder
122122
* → asset/template/<key>/<key>.asset-template-meta.json
123+
* • Both asset contexts fall back, in order, to flat mobile metadata,
124+
* flat webstudio metadata, then nested webstudio metadata in the same BU.
123125
* • r__dataExtension_key not found in the current BU
124126
* → also tries retrieve/cred/_ParentBU_/dataExtension/<key>.dataExtension-meta.json
125127
*
@@ -172,12 +174,21 @@ class RelatedItemLinkProvider implements VSCode.DocumentLinkProvider {
172174
let uri: VSCode.Uri | null = null;
173175

174176
if (type === "asset") {
175-
// Asset files use a subfolder per key and a subtype-dependent path
177+
// Preserve context-specific precedence, then try mobile and webstudio metadata.
176178
const subtype = isInsideAssetFolder ? "template" : "message";
177-
const files = await VSCode.workspace.findFiles(
178-
`${buPrefix}/asset/${subtype}/${key}/${key}.asset-${subtype}-meta.json`
179-
);
180-
if (files.length > 0) uri = files[0];
179+
const paths = [
180+
`${buPrefix}/asset/${subtype}/${key}/${key}.asset-${subtype}-meta.json`,
181+
`${buPrefix}/asset/mobile/${key}.asset-mobile-meta.json`,
182+
`${buPrefix}/asset/webstudio/${key}.asset-webstudio-meta.json`,
183+
`${buPrefix}/asset/webstudio/${key}/${key}.asset-webstudio-meta.json`
184+
];
185+
for (const path of paths) {
186+
const files = await VSCode.workspace.findFiles(path);
187+
if (files.length > 0) {
188+
uri = files[0];
189+
break;
190+
}
191+
}
181192
} else {
182193
// Standard pattern: TYPE/key.TYPE-meta.json
183194
const files = await VSCode.workspace.findFiles(`${buPrefix}/${type}/${key}.${type}-meta.json`);

src/test/suite/config/extension.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,9 @@ suite("Config – extension", () => {
8383
);
8484
});
8585

86-
test("package.json version is 3.4.2", () => {
86+
test("package.json version is 3.4.3", () => {
8787
const manifest = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), "package.json"), "utf8"));
88-
assert.strictEqual(manifest.version, "3.4.2");
88+
assert.strictEqual(manifest.version, "3.4.3");
8989
});
9090

9191
test("delayTimeUpdateStatusBar is a positive number", () => {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import * as assert from "assert";
2+
import { VSCode } from "@types";
3+
import RelatedItemLinkProvider from "../../../editor/relatedItemLinkProvider";
4+
import RelatedItemDiagnosticProvider from "../../../editor/relatedItemDiagnosticProvider";
5+
6+
const KEY = "shared-key";
7+
const BU = "retrieve/cred/bu";
8+
9+
/** Builds a single-line relation document in either asset context. */
10+
function documentFor(inside: boolean, bu = BU, text = `{"r__asset_key":"${KEY}"}`): VSCode.TextDocument {
11+
const path = `/workspace/${bu}/${inside ? "asset/message/source" : "journey"}/source.json`;
12+
return {
13+
uri: VSCode.Uri.file(path),
14+
getText: () => text,
15+
positionAt: (offset: number) => new VSCode.Position(0, offset)
16+
} as VSCode.TextDocument;
17+
}
18+
19+
/** Lists the exact metadata candidates in their required lookup order. */
20+
function candidates(inside: boolean, bu = BU): string[] {
21+
const subtype = inside ? "template" : "message";
22+
return [
23+
`${bu}/asset/${subtype}/${KEY}/${KEY}.asset-${subtype}-meta.json`,
24+
`${bu}/asset/mobile/${KEY}.asset-mobile-meta.json`,
25+
`${bu}/asset/webstudio/${KEY}.asset-webstudio-meta.json`,
26+
`${bu}/asset/webstudio/${KEY}/${KEY}.asset-webstudio-meta.json`
27+
];
28+
}
29+
30+
suite("Related item providers – asset metadata resolution", () => {
31+
const originalFindFiles = VSCode.workspace.findFiles;
32+
let files: Set<string>;
33+
let calls: string[];
34+
let links: RelatedItemLinkProvider;
35+
let diagnostics: RelatedItemDiagnosticProvider;
36+
37+
setup(() => {
38+
files = new Set();
39+
calls = [];
40+
links = new RelatedItemLinkProvider();
41+
diagnostics = new RelatedItemDiagnosticProvider();
42+
VSCode.workspace.findFiles = async (pattern, _exclude, maxResults) => {
43+
assert.strictEqual(typeof pattern, "string");
44+
const path = pattern as string;
45+
calls.push(path);
46+
const matches = path.endsWith("/**")
47+
? [...files].filter(file => file.startsWith(path.slice(0, -2)))
48+
: [...files].filter(file => file === path);
49+
return matches.slice(0, maxResults).map(file => VSCode.Uri.file(`/workspace/${file}`));
50+
};
51+
});
52+
53+
teardown(() => {
54+
VSCode.workspace.findFiles = originalFindFiles;
55+
diagnostics.getDiagnosticCollection().dispose();
56+
});
57+
58+
for (const inside of [false, true]) {
59+
const context = inside ? "inside asset" : "outside asset";
60+
for (const index of [0, 1, 2, 3]) {
61+
test(`${context}: candidate ${index} resolves and stops before lower-priority matches`, async () => {
62+
const paths = candidates(inside);
63+
// All later candidates exist too, proving precedence rather than mere discovery.
64+
files = new Set(paths.slice(index));
65+
const document = documentFor(inside);
66+
const result = await links.provideDocumentLinks(document);
67+
assert.strictEqual(result.length, 1);
68+
assert.strictEqual(result[0].target?.path, `/workspace/${paths[index]}`);
69+
const start = document.getText().indexOf(KEY);
70+
assert.strictEqual(result[0].range.start.character, start);
71+
assert.strictEqual(result[0].range.end.character, start + KEY.length);
72+
assert.deepStrictEqual(calls, paths.slice(0, index + 1));
73+
calls = [];
74+
await diagnostics.validateDocument(document);
75+
assert.deepStrictEqual(diagnostics.getDiagnosticCollection().get(document.uri), []);
76+
assert.deepStrictEqual(calls, [`${BU}/asset/**`, ...paths.slice(0, index + 1)]);
77+
});
78+
}
79+
80+
test(`${context}: missing key warns after every candidate, without searching other subtypes`, async () => {
81+
const paths = candidates(inside);
82+
// The opposite legacy context and source fragments must not satisfy this reference.
83+
files = new Set([candidates(!inside)[0], `${BU}/asset/webstudio/${KEY}/${KEY}.html`]);
84+
const document = documentFor(inside);
85+
assert.deepStrictEqual(await links.provideDocumentLinks(document), []);
86+
assert.deepStrictEqual(calls, paths);
87+
calls = [];
88+
await diagnostics.validateDocument(document);
89+
const result = diagnostics.getDiagnosticCollection().get(document.uri)!;
90+
assert.strictEqual(result.length, 1);
91+
assert.strictEqual(result[0].severity, VSCode.DiagnosticSeverity.Warning);
92+
assert.ok(result[0].message.includes("was not found on the BU"));
93+
assert.strictEqual((result[0].code as { value: string }).value, "warnOnMissingJsonRelation");
94+
assert.deepStrictEqual(calls, [`${BU}/asset/**`, ...paths]);
95+
});
96+
}
97+
98+
test("missing folder warns without attempting key resolution", async () => {
99+
const document = documentFor(false);
100+
assert.deepStrictEqual(await links.provideDocumentLinks(document), []);
101+
assert.deepStrictEqual(calls, candidates(false));
102+
calls = [];
103+
await diagnostics.validateDocument(document);
104+
const result = diagnostics.getDiagnosticCollection().get(document.uri)!;
105+
assert.strictEqual(result.length, 1);
106+
assert.strictEqual(result[0].severity, VSCode.DiagnosticSeverity.Warning);
107+
assert.ok(result[0].message.includes("type folder has not been retrieved"));
108+
assert.deepStrictEqual(calls, [`${BU}/asset/**`]);
109+
});
110+
111+
test("typeFilter rejects the relation before any filesystem lookup", async () => {
112+
diagnostics.getDiagnosticCollection().dispose();
113+
diagnostics = new RelatedItemDiagnosticProvider((type, project) => {
114+
assert.strictEqual(type, "asset");
115+
assert.strictEqual(project, "/workspace");
116+
return false;
117+
});
118+
const document = documentFor(false);
119+
await diagnostics.validateDocument(document);
120+
assert.deepStrictEqual(diagnostics.getDiagnosticCollection().get(document.uri), []);
121+
assert.deepStrictEqual(calls, []);
122+
});
123+
124+
for (const present of [false, true]) {
125+
test(`sequential lookups reuse ${present ? "positive" : "negative"} caches`, async () => {
126+
const target = candidates(false)[3];
127+
files.add(`${BU}/asset/other.json`);
128+
if (present) files.add(target);
129+
const document = documentFor(false);
130+
const firstLinks = await links.provideDocumentLinks(document);
131+
await diagnostics.validateDocument(document);
132+
const firstDiagnostics = diagnostics.getDiagnosticCollection().get(document.uri);
133+
assert.strictEqual(firstLinks.length, present ? 1 : 0);
134+
assert.strictEqual(firstDiagnostics?.length, present ? 0 : 1);
135+
assert.strictEqual(calls.length, 9);
136+
calls = [];
137+
// Change the backing files; cache behavior intentionally remains unchanged.
138+
if (present) files.delete(target);
139+
else files.add(target);
140+
assert.deepStrictEqual(await links.provideDocumentLinks(document), firstLinks);
141+
await diagnostics.validateDocument(document);
142+
assert.deepStrictEqual(diagnostics.getDiagnosticCollection().get(document.uri), firstDiagnostics);
143+
assert.deepStrictEqual(calls, []);
144+
});
145+
}
146+
147+
test("same key caches remain separated by BU and asset context", async () => {
148+
const otherBu = "retrieve/cred/other-bu";
149+
files = new Set([candidates(false)[0], candidates(true)[0], `${otherBu}/asset/other.json`]);
150+
for (const [inside, bu, expected] of [
151+
[false, BU, candidates(false)[0]],
152+
[true, BU, candidates(true)[0]],
153+
[false, otherBu, undefined]
154+
] as const) {
155+
calls = [];
156+
const document = documentFor(inside, bu);
157+
const result = await links.provideDocumentLinks(document);
158+
assert.strictEqual(result.length, expected ? 1 : 0);
159+
assert.strictEqual(result[0]?.target?.path, expected ? `/workspace/${expected}` : undefined);
160+
await diagnostics.validateDocument(document);
161+
assert.strictEqual(diagnostics.getDiagnosticCollection().get(document.uri)?.length, expected ? 0 : 1);
162+
assert.ok(calls.length > 0);
163+
assert.ok(calls.every(path => path.startsWith(`${bu}/`)));
164+
}
165+
});
166+
167+
test("automation asset relations retain forward and reverse parsing", async () => {
168+
files.add(candidates(false)[1]);
169+
for (const text of [`{"r__type":"asset","r__key":"${KEY}"}`, `{"r__key":"${KEY}","r__type":"asset"}`]) {
170+
const document = documentFor(false, BU, text);
171+
const result = await links.provideDocumentLinks(document);
172+
assert.strictEqual(result.length, 1);
173+
assert.strictEqual(result[0].target?.path, `/workspace/${candidates(false)[1]}`);
174+
assert.strictEqual(result[0].range.start.character, text.indexOf(KEY));
175+
await diagnostics.validateDocument(document);
176+
assert.deepStrictEqual(diagnostics.getDiagnosticCollection().get(document.uri), []);
177+
}
178+
});
179+
});

0 commit comments

Comments
 (0)