Skip to content
Open
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
6 changes: 3 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
"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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why does this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required for the Mocha Test Explorer extension to find the tests in the VS Code test explorer.

Do you think we should add this extension to the recommended extensions in .vscode/extensions.json? I can add that to this PR if you want. The settings for Mocha Test Explorer are there, but there isn't an indication that the extension is used by this project.

"mochaExplorer.require": "ts-node/register",
"mochaExplorer.env": {
"TS_NODE_PROJECT": "src/test/tsconfig.json"
},
"files.associations": {
"devcontainer-features.json": "jsonc"
},
"typescript.tsdk": "node_modules/typescript/lib",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"git.branchProtection": [
"main",
"release/*"
Expand Down
16 changes: 14 additions & 2 deletions src/spec-configuration/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,24 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf
return;
}

const newLockfileContentString = JSON.stringify(lockfile, null, 2);
// 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.');
}
if (!oldLockfileContent || !newLockfileContent.equals(oldLockfileContent)) {
// 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 {
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.');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@
]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/codspace/features/flower:1": {},
"ghcr.io/codspace/features/color:1": {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@
"integrity": "sha256:c9cc1ac636b9ef595512b5ca7ecb3a35b7d3499cb6f86372edec76ae0cd71d43"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@
"integrity": "sha256:c9cc1ac636b9ef595512b5ca7ecb3a35b7d3499cb6f86372edec76ae0cd71d43"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
"integrity": "sha256:9024deeca80347dea7603a3bb5b4951988f0bf5894ba036a6ee3f29c025692c6"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@
"integrity": "sha256:41607bd6aba3975adcd0641cc479e67b04abd21763ba8a41ea053bcc04a6a818"
}
}
}
}
104 changes: 103 additions & 1 deletion src/test/container-features/lockfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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');
Expand All @@ -298,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);
}
});
});
6 changes: 5 additions & 1 deletion src/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"resolveJsonModule": true
"resolveJsonModule": true,
"types": [
"node",
"mocha"
]
},
"references": [
{
Expand Down