Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"hbenl.vscode-mocha-test-adapter"
]
}
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",
Comment thread
abdurriq marked this conversation as resolved.
"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.).
Comment thread
chrmarti marked this conversation as resolved.
Outdated
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