From 8411fdbb567bb05cde2a7ab0ebe173aa06b790b4 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:25:02 -0400 Subject: [PATCH 1/4] fix newline issue Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .vscode/settings.json | 6 +-- src/spec-configuration/lockfile.ts | 12 +++++- .../expected.devcontainer-lock.json | 2 +- .../lockfile-frozen/.devcontainer-lock.json | 2 +- .../expected.devcontainer-lock.json | 2 +- .../upgraded.devcontainer-lock.json | 2 +- .../lockfile/expected.devcontainer-lock.json | 2 +- src/test/container-features/lockfile.test.ts | 40 ++++++++++++++++++- 8 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a5ac98e49..1963914f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,13 +3,13 @@ "search.exclude": { "dist": true }, - "typescript.tsc.autoDetect": "off", + "js/ts.tsc.autoDetect": "off", "eslint.options": { "rulePaths": [ "./build/eslint" ] }, - "mochaExplorer.files": "test/**/*.test.ts", + "mochaExplorer.files": "src/test/**/*.test.ts", "mochaExplorer.require": "ts-node/register", "mochaExplorer.env": { "TS_NODE_PROJECT": "src/test/tsconfig.json" @@ -17,7 +17,7 @@ "files.associations": { "devcontainer-features.json": "jsonc" }, - "typescript.tsdk": "node_modules/typescript/lib", + "js/ts.tsdk.path": "node_modules/typescript/lib", "git.branchProtection": [ "main", "release/*" diff --git a/src/spec-configuration/lockfile.ts b/src/spec-configuration/lockfile.ts index 9de0ba0b2..53fbd0fb9 100644 --- a/src/spec-configuration/lockfile.ts +++ b/src/spec-configuration/lockfile.ts @@ -53,12 +53,20 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf return; } - const newLockfileContentString = JSON.stringify(lockfile, null, 2); + const newLockfileContentString = JSON.stringify(lockfile, null, 2) + '\n'; const newLockfileContent = Buffer.from(newLockfileContentString); if (params.experimentalFrozenLockfile && !oldLockfileContent) { throw new Error('Lockfile does not exist.'); } - if (!oldLockfileContent || !newLockfileContent.equals(oldLockfileContent)) { + let oldLockfileNormalized: string | undefined; + if (oldLockfileContent) { + try { + oldLockfileNormalized = JSON.stringify(JSON.parse(oldLockfileContent.toString()), null, 2) + '\n'; + } catch { + // Empty or invalid JSON; treat as needing rewrite. + } + } + if (!oldLockfileNormalized || oldLockfileNormalized !== newLockfileContentString) { if (params.experimentalFrozenLockfile) { throw new Error('Lockfile does not match.'); } diff --git a/src/test/container-features/configs/lockfile-dependson/expected.devcontainer-lock.json b/src/test/container-features/configs/lockfile-dependson/expected.devcontainer-lock.json index a3ed4b5d2..90f784d43 100644 --- a/src/test/container-features/configs/lockfile-dependson/expected.devcontainer-lock.json +++ b/src/test/container-features/configs/lockfile-dependson/expected.devcontainer-lock.json @@ -27,4 +27,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/test/container-features/configs/lockfile-frozen/.devcontainer-lock.json b/src/test/container-features/configs/lockfile-frozen/.devcontainer-lock.json index 4d9bc604e..1e1764123 100644 --- a/src/test/container-features/configs/lockfile-frozen/.devcontainer-lock.json +++ b/src/test/container-features/configs/lockfile-frozen/.devcontainer-lock.json @@ -11,4 +11,4 @@ "integrity": "sha256:c9cc1ac636b9ef595512b5ca7ecb3a35b7d3499cb6f86372edec76ae0cd71d43" } } -} \ No newline at end of file +} diff --git a/src/test/container-features/configs/lockfile-outdated/expected.devcontainer-lock.json b/src/test/container-features/configs/lockfile-outdated/expected.devcontainer-lock.json index d4491d4dc..8d2fa74e0 100644 --- a/src/test/container-features/configs/lockfile-outdated/expected.devcontainer-lock.json +++ b/src/test/container-features/configs/lockfile-outdated/expected.devcontainer-lock.json @@ -11,4 +11,4 @@ "integrity": "sha256:c9cc1ac636b9ef595512b5ca7ecb3a35b7d3499cb6f86372edec76ae0cd71d43" } } -} \ No newline at end of file +} diff --git a/src/test/container-features/configs/lockfile-upgrade-command/upgraded.devcontainer-lock.json b/src/test/container-features/configs/lockfile-upgrade-command/upgraded.devcontainer-lock.json index bb0b7103b..f3916df75 100644 --- a/src/test/container-features/configs/lockfile-upgrade-command/upgraded.devcontainer-lock.json +++ b/src/test/container-features/configs/lockfile-upgrade-command/upgraded.devcontainer-lock.json @@ -21,4 +21,4 @@ "integrity": "sha256:9024deeca80347dea7603a3bb5b4951988f0bf5894ba036a6ee3f29c025692c6" } } -} \ No newline at end of file +} diff --git a/src/test/container-features/configs/lockfile/expected.devcontainer-lock.json b/src/test/container-features/configs/lockfile/expected.devcontainer-lock.json index dd3136f9d..8e85b136b 100644 --- a/src/test/container-features/configs/lockfile/expected.devcontainer-lock.json +++ b/src/test/container-features/configs/lockfile/expected.devcontainer-lock.json @@ -16,4 +16,4 @@ "integrity": "sha256:41607bd6aba3975adcd0641cc479e67b04abd21763ba8a41ea053bcc04a6a818" } } -} \ No newline at end of file +} diff --git a/src/test/container-features/lockfile.test.ts b/src/test/container-features/lockfile.test.ts index 96fcd4045..d7417b0fb 100644 --- a/src/test/container-features/lockfile.test.ts +++ b/src/test/container-features/lockfile.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import * as path from 'path'; import * as semver from 'semver'; import { shellExec } from '../testUtils'; -import { cpLocal, readLocalFile, rmLocal } from '../../spec-utils/pfs'; +import { cpLocal, readLocalFile, rmLocal, writeLocalFile } from '../../spec-utils/pfs'; const pkg = require('../../../package.json'); @@ -279,6 +279,44 @@ describe('Lockfile', function () { } }); + it('lockfile ends with trailing newline', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile'); + + const lockfilePath = path.join(workspaceFolder, '.devcontainer-lock.json'); + await rmLocal(lockfilePath, { force: true }); + + const res = await shellExec(`${cli} build --workspace-folder ${workspaceFolder} --experimental-lockfile`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + const actual = (await readLocalFile(lockfilePath)).toString(); + assert.ok(actual.endsWith('\n'), 'Lockfile should end with a trailing newline'); + }); + + it('frozen lockfile matches despite formatting differences', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-frozen'); + const lockfilePath = path.join(workspaceFolder, '.devcontainer-lock.json'); + + // Read the existing lockfile, strip trailing newline to create a byte-different but semantically identical file + const original = (await readLocalFile(lockfilePath)).toString(); + const stripped = original.replace(/\n$/, ''); + assert.notEqual(original, stripped, 'Test setup: should have removed trailing newline'); + assert.deepEqual(JSON.parse(original), JSON.parse(stripped), 'Test setup: JSON content should be identical'); + + try { + await writeLocalFile(lockfilePath, Buffer.from(stripped)); + + // Frozen lockfile should succeed because JSON content is the same + const res = await shellExec(`${cli} build --workspace-folder ${workspaceFolder} --experimental-lockfile --experimental-frozen-lockfile`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success', 'Frozen lockfile should not fail when only formatting differs'); + const actual = (await readLocalFile(lockfilePath)).toString(); + assert.strictEqual(actual, stripped, 'Frozen lockfile should remain unchanged when only formatting differs'); + } finally { + // Restore original lockfile + await writeLocalFile(lockfilePath, Buffer.from(original)); + } + }); + it('upgrade command should work with default workspace folder', async () => { const workspaceFolder = path.join(__dirname, 'configs/lockfile-upgrade-command'); const absoluteTmpPath = path.resolve(__dirname, 'tmp'); From d92517017d5083077fc132755859229254aad2a8 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:39:45 -0400 Subject: [PATCH 2/4] test tsconfig for mocha type support in editor Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- src/test/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/tsconfig.json b/src/test/tsconfig.json index 0b742af1e..7059b13e4 100644 --- a/src/test/tsconfig.json +++ b/src/test/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node", "mocha"] }, "references": [ { From cccd7857d37d1b0e8891d434e7f05b6953037733 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:30:08 -0400 Subject: [PATCH 3/4] additional tests Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- src/spec-configuration/lockfile.ts | 4 ++ .../.devcontainer.json | 7 ++ src/test/container-features/lockfile.test.ts | 64 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/test/container-features/configs/lockfile-frozen-no-lockfile/.devcontainer.json diff --git a/src/spec-configuration/lockfile.ts b/src/spec-configuration/lockfile.ts index 53fbd0fb9..d6b230cb1 100644 --- a/src/spec-configuration/lockfile.ts +++ b/src/spec-configuration/lockfile.ts @@ -53,11 +53,15 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf return; } + // Trailing newline per POSIX convention const newLockfileContentString = JSON.stringify(lockfile, null, 2) + '\n'; const newLockfileContent = Buffer.from(newLockfileContentString); if (params.experimentalFrozenLockfile && !oldLockfileContent) { throw new Error('Lockfile does not exist.'); } + // Normalize the existing lockfile through JSON.parse -> JSON.stringify to produce + // the same canonical format as newLockfileContentString, so that the string comparison + // below ignores cosmetic differences (indentation, key order, trailing whitespace, etc.). let oldLockfileNormalized: string | undefined; if (oldLockfileContent) { try { diff --git a/src/test/container-features/configs/lockfile-frozen-no-lockfile/.devcontainer.json b/src/test/container-features/configs/lockfile-frozen-no-lockfile/.devcontainer.json new file mode 100644 index 000000000..d98d20705 --- /dev/null +++ b/src/test/container-features/configs/lockfile-frozen-no-lockfile/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/codspace/features/flower:1": {}, + "ghcr.io/codspace/features/color:1": {} + } +} diff --git a/src/test/container-features/lockfile.test.ts b/src/test/container-features/lockfile.test.ts index d7417b0fb..8702429b1 100644 --- a/src/test/container-features/lockfile.test.ts +++ b/src/test/container-features/lockfile.test.ts @@ -336,4 +336,68 @@ describe('Lockfile', function () { process.chdir(originalCwd); } }); + + it('frozen lockfile fails when lockfile does not exist', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile-frozen-no-lockfile'); + const lockfilePath = path.join(workspaceFolder, '.devcontainer-lock.json'); + await rmLocal(lockfilePath, { force: true }); + + try { + throw await shellExec(`${cli} build --workspace-folder ${workspaceFolder} --experimental-lockfile --experimental-frozen-lockfile`); + } catch (res) { + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'error'); + assert.equal(response.message, 'Lockfile does not exist.'); + } + }); + + it('corrupt lockfile causes build error', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile'); + const lockfilePath = path.join(workspaceFolder, '.devcontainer-lock.json'); + const expectedPath = path.join(workspaceFolder, 'expected.devcontainer-lock.json'); + + try { + // Write invalid JSON to the lockfile + await writeLocalFile(lockfilePath, Buffer.from('this is not valid json{{{')); + + try { + throw await shellExec(`${cli} build --workspace-folder ${workspaceFolder} --experimental-lockfile`); + } catch (res) { + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'error'); + } + } finally { + // Restore from the known-good expected lockfile + await cpLocal(expectedPath, lockfilePath); + } + }); + + it('no lockfile flags and no existing lockfile is a no-op', async () => { + const workspaceFolder = path.join(__dirname, 'configs/lockfile'); + const lockfilePath = path.join(workspaceFolder, '.devcontainer-lock.json'); + const expectedPath = path.join(workspaceFolder, 'expected.devcontainer-lock.json'); + + try { + await rmLocal(lockfilePath, { force: true }); + + // Build without any lockfile flags + const res = await shellExec(`${cli} build --workspace-folder ${workspaceFolder}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + // Lockfile should not have been created + let exists = true; + await readLocalFile(lockfilePath).catch(err => { + if (err?.code === 'ENOENT') { + exists = false; + } else { + throw err; + } + }); + assert.equal(exists, false, 'Lockfile should not be created when no lockfile flags are set'); + } finally { + // Restore from the known-good expected lockfile + await cpLocal(expectedPath, lockfilePath); + } + }); }); \ No newline at end of file From 780fd4793238a88d90dbcfb2cc42a7a3640cd0df Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:30:54 -0400 Subject: [PATCH 4/4] formatting Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- src/test/tsconfig.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/tsconfig.json b/src/test/tsconfig.json index 7059b13e4..b5be01ce1 100644 --- a/src/test/tsconfig.json +++ b/src/test/tsconfig.json @@ -2,7 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "resolveJsonModule": true, - "types": ["node", "mocha"] + "types": [ + "node", + "mocha" + ] }, "references": [ {