import { strict as assert } from 'node:assert' import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { test } from 'node:test' import ts from 'typescript' const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true) function loadFunction(name: string, dependencies: Record) { const declaration = source.statements.find((item) => ts.isFunctionDeclaration(item) && item.name?.text === name) assert.ok(declaration) const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 }) return new Function(...Object.keys(dependencies), `${code}; return ${name};`)(...Object.values(dependencies)) } function command(executable: string, args: string[], cwd: string) { const result = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 15000, env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com' }, }) assert.equal(result.status, 0, result.stderr || String(result.error)) return result.stdout.trim() } function fixture() { const root = mkdtempSync(join(tmpdir(), 'lms-preserve-')) const template = join(root, 'template') mkdirSync(template) command('git', ['init', '-b', 'main'], template) writeFileSync(join(template, 'node.py'), 'template source\n') writeFileSync(join(template, '.gitignore'), 'build/\ninstall/\nlog/\n') command('git', ['add', '.'], template) command('git', ['commit', '-m', 'template'], template) const install = loadFunction('installRemoteWorkspaceModule', { normalizeRemoteTarget: (target: unknown) => target, remoteRepositoryUrl: (url: string) => url, withGitCredential: (url: string) => url, remotePathJoin: join, safeArtifactFileName: () => 'unused.tar', quoteShellSingle: (value: string) => "'" + value.replaceAll("'", "'\\''") + "'", encodeBase64: (value: string) => Buffer.from(value).toString('base64'), expandRemotePathVar: () => [], runSsh: async (_target: unknown, script: string) => command('bash', ['-c', script], root), parseModuleFolderName: () => ({ name: 'Test', category: 'Control' }), }) const target = { workspaceRoot: join(root, 'workspace') } return { root, template, target, install, modulePath: join(target.workspaceRoot, 'module') } } test('reinstall preserves saved source, local commits, untracked files and build outputs despite changed repository URLs', async () => { const f = fixture() try { await f.install(f.target, 'Test', 'module', f.template) writeFileSync(join(f.modulePath, 'node.py'), 'student commit\n') command('git', ['add', 'node.py'], f.modulePath) command('git', ['commit', '-m', 'student work'], f.modulePath) const head = command('git', ['rev-parse', 'HEAD'], f.modulePath) writeFileSync(join(f.modulePath, 'node.py'), 'saved but not committed\n') writeFileSync(join(f.modulePath, 'new.py'), 'new student file\n') for (const dir of ['build', 'install', 'log']) { mkdirSync(join(f.modulePath, dir)) writeFileSync(join(f.modulePath, dir, 'keep'), dir) } const status = command('git', ['status', '--porcelain'], f.modulePath) await f.install(f.target, 'Test', 'module', '/missing/new-template', '/missing/new-student', { prebuiltArtifacts: [{ url: 'https://invalid.example/overwrite.tar', required: true }], }) assert.equal(command('git', ['rev-parse', 'HEAD'], f.modulePath), head) assert.equal(command('git', ['status', '--porcelain'], f.modulePath), status) assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'saved but not committed\n') assert.equal(readFileSync(join(f.modulePath, 'new.py'), 'utf8'), 'new student file\n') for (const dir of ['build', 'install', 'log']) assert.equal(readFileSync(join(f.modulePath, dir, 'keep'), 'utf8'), dir) } finally { rmSync(f.root, { recursive: true, force: true }) } }) test('first install restores the student repository instead of replacing it with template source', async () => { const f = fixture() try { const student = join(f.root, 'student') command('git', ['clone', f.template, student], f.root) writeFileSync(join(student, 'node.py'), 'submitted student work\n') command('git', ['add', '.'], student) command('git', ['commit', '-m', 'submission'], student) await f.install(f.target, 'Test', 'module', f.template, student) assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'submitted student work\n') assert.equal(command('git', ['branch', '--show-current'], f.modulePath), 'student-work') } finally { rmSync(f.root, { recursive: true, force: true }) } }) test('first install initializes an empty student repository', async () => { const f = fixture() try { const student = join(f.root, 'student.git') command('git', ['init', '--bare', student], f.root) await f.install(f.target, 'Test', 'module', f.template, student) assert.equal(command('git', ['--git-dir', student, 'show', 'main:node.py'], f.root), 'template source') } finally { rmSync(f.root, { recursive: true, force: true }) } }) test('legacy session reset only stops modules and returns stop failures', async () => { const target = { workspaceRoot: '/student/workspace' } const modules = [{ moduleName: 'Test', folderName: 'module', templateRepositoryUrl: '/template' }] const result = { ok: false, message: 'stop failed' } let calls = 0 const reset = loadFunction('resetRemoteWorkspaceSession', { stopRemoteWorkspaceModules: async (actualTarget: unknown, actualModules: unknown) => { calls++ assert.equal(actualTarget, target) assert.equal(actualModules, modules) return result }, }) assert.equal(await reset(target, modules), result) assert.equal(calls, 1) })