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
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, 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