mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add a one-shot OD_LEGACY_DATA_DIR migrator so packaged Desktop users can recover 0.3.x repo .od data into the 0.4.x data root. The migrator stages payloads before promotion, refuses unsafe merges and symlinks, rolls back failed promotion or marker writes, and extends packaged daemon startup handling for long migrations while failing fast on daemon exits. Closes #710
535 lines
20 KiB
TypeScript
535 lines
20 KiB
TypeScript
/**
|
|
* Unit tests for the OD_LEGACY_DATA_DIR one-shot migrator. Hermetic:
|
|
* each test runs against a fresh pair of mkdtemp() directories so no
|
|
* test ever touches a real OD install.
|
|
*
|
|
* The migrator's contract is "loud or correct": when OD_LEGACY_DATA_DIR
|
|
* is set, either the daemon migrates cleanly or it throws a
|
|
* LegacyMigrationError the launcher can surface. Silently launching
|
|
* empty was the original #710 footgun.
|
|
*
|
|
* @see apps/daemon/src/legacy-data-migrator.ts
|
|
* @see https://github.com/nexu-io/open-design/issues/710
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { rm } from 'node:fs/promises';
|
|
import * as os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
LegacyMigrationError,
|
|
dataDirHasExistingPayload,
|
|
dataDirIsEmptyOrFresh,
|
|
legacyDirHasPayload,
|
|
migrateLegacyDataDirSync,
|
|
promoteStaged,
|
|
} from '../src/legacy-data-migrator.js';
|
|
|
|
interface SilentLogger {
|
|
info(message: string): void;
|
|
warn(message: string): void;
|
|
readonly entries: { level: 'info' | 'warn'; message: string }[];
|
|
}
|
|
|
|
function makeLogger(): SilentLogger {
|
|
const entries: { level: 'info' | 'warn'; message: string }[] = [];
|
|
return {
|
|
entries,
|
|
info: (m) => entries.push({ level: 'info', message: m }),
|
|
warn: (m) => entries.push({ level: 'warn', message: m }),
|
|
};
|
|
}
|
|
|
|
function writeFile(filePath: string, contents = 'x'): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, contents, 'utf8');
|
|
}
|
|
|
|
function seedLegacyDir(legacyDir: string): void {
|
|
// A typical 0.3.x .od/ payload: SQLite, a project's CWD, an artifact,
|
|
// and the media-config credentials file.
|
|
writeFile(path.join(legacyDir, 'app.sqlite'), 'fake-sqlite');
|
|
writeFile(path.join(legacyDir, 'app-config.json'), '{"v":1}');
|
|
writeFile(path.join(legacyDir, 'media-config.json'), '{"providers":{}}');
|
|
writeFile(path.join(legacyDir, 'projects', 'p1', 'index.html'), '<html>1</html>');
|
|
writeFile(path.join(legacyDir, 'projects', 'p2', 'page.html'), '<html>2</html>');
|
|
writeFile(path.join(legacyDir, 'artifacts', 'a1', 'final.html'), '<html>a</html>');
|
|
}
|
|
|
|
/**
|
|
* Symlinks need elevated permission on stock Windows. Skip the test
|
|
* cleanly there rather than failing the suite for an unrelated OS
|
|
* limitation.
|
|
*/
|
|
function trySymlink(target: string, linkPath: string, type: 'dir' | 'file'): boolean {
|
|
try {
|
|
fs.symlinkSync(target, linkPath, type === 'dir' ? 'junction' : 'file');
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
describe('migrateLegacyDataDirSync', () => {
|
|
let legacyDir: string;
|
|
let dataDir: string;
|
|
|
|
beforeEach(() => {
|
|
legacyDir = mkdtempSync(path.join(os.tmpdir(), 'od-legacy-'));
|
|
dataDir = mkdtempSync(path.join(os.tmpdir(), 'od-data-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(legacyDir, { recursive: true, force: true });
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns noop when OD_LEGACY_DATA_DIR is not set', () => {
|
|
const log = makeLogger();
|
|
expect(
|
|
migrateLegacyDataDirSync({ legacyDir: undefined, dataDir, logger: log }),
|
|
).toMatchObject({ status: 'noop' });
|
|
expect(log.entries).toHaveLength(0);
|
|
});
|
|
|
|
it('returns noop when OD_LEGACY_DATA_DIR is the empty string', () => {
|
|
expect(
|
|
migrateLegacyDataDirSync({ legacyDir: '', dataDir, logger: makeLogger() }),
|
|
).toMatchObject({ status: 'noop' });
|
|
});
|
|
|
|
it('returns noop when legacyDir equals dataDir', () => {
|
|
expect(
|
|
migrateLegacyDataDirSync({ legacyDir: dataDir, dataDir, logger: makeLogger() }),
|
|
).toMatchObject({ status: 'noop' });
|
|
});
|
|
|
|
it('throws LegacyMigrationError when legacyDir has no app.sqlite', () => {
|
|
// Failing loud here is the point of the contract: a typo in the env
|
|
// var or a stale path used to silently skip and then drop the user
|
|
// into an empty workspace, which is exactly the #710 footgun.
|
|
writeFile(path.join(legacyDir, 'README.txt'), 'unrelated');
|
|
expect(() =>
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() }),
|
|
).toThrowError(LegacyMigrationError);
|
|
|
|
try {
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() });
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(LegacyMigrationError);
|
|
expect((err as LegacyMigrationError).code).toBe('legacy_dir_invalid');
|
|
}
|
|
});
|
|
|
|
it('throws LegacyMigrationError when dataDir already has app.sqlite', () => {
|
|
seedLegacyDir(legacyDir);
|
|
writeFile(path.join(dataDir, 'app.sqlite'), 'NEW-DATA-DO-NOT-OVERWRITE');
|
|
|
|
let captured: unknown;
|
|
try {
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() });
|
|
} catch (err) {
|
|
captured = err;
|
|
}
|
|
expect(captured).toBeInstanceOf(LegacyMigrationError);
|
|
expect((captured as LegacyMigrationError).code).toBe('data_dir_not_empty');
|
|
|
|
// The existing payload must be untouched.
|
|
expect(fs.readFileSync(path.join(dataDir, 'app.sqlite'), 'utf8')).toBe(
|
|
'NEW-DATA-DO-NOT-OVERWRITE',
|
|
);
|
|
});
|
|
|
|
it('throws when dataDir already has a payload entry other than app.sqlite', () => {
|
|
// A half-overlaid project tree is worse than no migration: cpSync
|
|
// would merge directory contents and SQLite/WAL pairs cannot be
|
|
// safely interleaved with foreign WAL pages.
|
|
seedLegacyDir(legacyDir);
|
|
writeFile(path.join(dataDir, 'projects', 'preexisting', 'index.html'), '<html/>');
|
|
|
|
let captured: unknown;
|
|
try {
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() });
|
|
} catch (err) {
|
|
captured = err;
|
|
}
|
|
expect(captured).toBeInstanceOf(LegacyMigrationError);
|
|
expect((captured as LegacyMigrationError).code).toBe('data_dir_not_empty');
|
|
expect(
|
|
fs.existsSync(path.join(dataDir, 'projects', 'preexisting', 'index.html')),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('migrates payload to a fresh dataDir', () => {
|
|
seedLegacyDir(legacyDir);
|
|
const log = makeLogger();
|
|
const result = migrateLegacyDataDirSync({ legacyDir, dataDir, logger: log });
|
|
|
|
expect(result.status).toBe('migrated');
|
|
expect(result.copied).toEqual(
|
|
expect.arrayContaining([
|
|
'app.sqlite',
|
|
'app-config.json',
|
|
'media-config.json',
|
|
'projects',
|
|
'artifacts',
|
|
]),
|
|
);
|
|
expect(fs.readFileSync(path.join(dataDir, 'app.sqlite'), 'utf8')).toBe('fake-sqlite');
|
|
expect(
|
|
fs.readFileSync(path.join(dataDir, 'projects', 'p1', 'index.html'), 'utf8'),
|
|
).toBe('<html>1</html>');
|
|
expect(
|
|
fs.readFileSync(path.join(dataDir, 'artifacts', 'a1', 'final.html'), 'utf8'),
|
|
).toBe('<html>a</html>');
|
|
expect(log.entries.some((e) => e.message.includes('migrating legacy data'))).toBe(true);
|
|
expect(log.entries.some((e) => e.message.includes('migration complete'))).toBe(true);
|
|
});
|
|
|
|
it('writes a .migrated-from marker after success', () => {
|
|
seedLegacyDir(legacyDir);
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() });
|
|
const marker = JSON.parse(
|
|
fs.readFileSync(path.join(dataDir, '.migrated-from'), 'utf8'),
|
|
);
|
|
expect(marker.legacyDir).toBe(path.resolve(legacyDir));
|
|
expect(typeof marker.migratedAt).toBe('string');
|
|
});
|
|
|
|
it('idempotent: a second call after success returns skipped without re-copying', () => {
|
|
seedLegacyDir(legacyDir);
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() });
|
|
|
|
// Drop the SQLite to simulate a developer wipe; the marker should
|
|
// still cause the second call to skip so we don't keep dragging
|
|
// legacy state back over a clean slate the user wanted.
|
|
fs.rmSync(path.join(dataDir, 'app.sqlite'));
|
|
|
|
const result = migrateLegacyDataDirSync({
|
|
legacyDir,
|
|
dataDir,
|
|
logger: makeLogger(),
|
|
});
|
|
expect(result.status).toBe('skipped');
|
|
expect(result.reason).toMatch(/marker/);
|
|
expect(fs.existsSync(path.join(dataDir, 'app.sqlite'))).toBe(false);
|
|
});
|
|
|
|
it('marker beats a missing legacyDir on the next boot', async () => {
|
|
// mrcfps round-6: the marker is the canonical "do not touch"
|
|
// signal once a migration has succeeded. If the user leaves
|
|
// OD_LEGACY_DATA_DIR set after success and then deletes or moves
|
|
// the old `.od/` (which is the documented launchctl setenv path),
|
|
// the next boot must still no-op via the marker rather than
|
|
// throwing legacy_dir_invalid for re-validating a source the
|
|
// marker contract says is no longer needed.
|
|
seedLegacyDir(legacyDir);
|
|
const first = migrateLegacyDataDirSync({
|
|
legacyDir,
|
|
dataDir,
|
|
logger: makeLogger(),
|
|
});
|
|
expect(first.status).toBe('migrated');
|
|
|
|
// Simulate the user removing the old repo `.od/` after the
|
|
// successful migration but leaving OD_LEGACY_DATA_DIR set.
|
|
await rm(legacyDir, { recursive: true, force: true });
|
|
|
|
const second = migrateLegacyDataDirSync({
|
|
legacyDir,
|
|
dataDir,
|
|
logger: makeLogger(),
|
|
});
|
|
expect(second.status).toBe('skipped');
|
|
expect(second.reason).toMatch(/marker/);
|
|
});
|
|
|
|
it('migrates even when dataDir does not exist yet', async () => {
|
|
seedLegacyDir(legacyDir);
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
|
|
const result = migrateLegacyDataDirSync({
|
|
legacyDir,
|
|
dataDir,
|
|
logger: makeLogger(),
|
|
});
|
|
expect(result.status).toBe('migrated');
|
|
expect(fs.existsSync(path.join(dataDir, 'app.sqlite'))).toBe(true);
|
|
});
|
|
|
|
it('skips entries that are absent in the legacy dir', () => {
|
|
// Only seed the SQLite + one project; no media-config, no artifacts.
|
|
writeFile(path.join(legacyDir, 'app.sqlite'), 'fake');
|
|
writeFile(path.join(legacyDir, 'projects', 'only', 'page.html'), '<x/>');
|
|
|
|
const result = migrateLegacyDataDirSync({
|
|
legacyDir,
|
|
dataDir,
|
|
logger: makeLogger(),
|
|
});
|
|
expect(result.status).toBe('migrated');
|
|
expect(result.copied).toContain('app.sqlite');
|
|
expect(result.copied).toContain('projects');
|
|
expect(result.copied).not.toContain('media-config.json');
|
|
expect(result.copied).not.toContain('artifacts');
|
|
});
|
|
|
|
it('refuses to migrate when a symlink is anywhere inside the legacy payload', () => {
|
|
// Without this guard, fs.cpSync would preserve the link and
|
|
// downstream readers (projects.ts) would follow it, escaping the
|
|
// data root via a crafted .od/ tree.
|
|
seedLegacyDir(legacyDir);
|
|
|
|
const escapeTarget = mkdtempSync(path.join(os.tmpdir(), 'od-escape-'));
|
|
writeFile(path.join(escapeTarget, 'secret.txt'), 'should not be reachable');
|
|
const linkPath = path.join(legacyDir, 'projects', 'evil-link');
|
|
|
|
if (!trySymlink(escapeTarget, linkPath, 'dir')) {
|
|
// Stock Windows without elevation rejects symlink creation; skip
|
|
// the assertion rather than failing for an unrelated reason.
|
|
return;
|
|
}
|
|
|
|
let captured: unknown;
|
|
try {
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() });
|
|
} catch (err) {
|
|
captured = err;
|
|
}
|
|
expect(captured).toBeInstanceOf(LegacyMigrationError);
|
|
expect((captured as LegacyMigrationError).code).toBe('symlink_in_payload');
|
|
|
|
// dataDir must remain pristine so a follow-up cleanup-then-retry
|
|
// works.
|
|
expect(fs.existsSync(path.join(dataDir, '.migrated-from'))).toBe(false);
|
|
expect(fs.existsSync(path.join(dataDir, 'app.sqlite'))).toBe(false);
|
|
|
|
fs.rmSync(escapeTarget, { recursive: true, force: true });
|
|
});
|
|
|
|
it('cleans up the staging dir on staging failure (no half-copied state in dataDir)', () => {
|
|
// Legacy dir has SQLite plus a symlink under projects/. Staging
|
|
// copies SQLite first, then walks projects/ and rejects the symlink.
|
|
// The staging dir must be removed so a retry does not see leftover
|
|
// partial copies.
|
|
seedLegacyDir(legacyDir);
|
|
|
|
const escapeTarget = mkdtempSync(path.join(os.tmpdir(), 'od-stage-cleanup-'));
|
|
const linkPath = path.join(legacyDir, 'projects', 'late-link');
|
|
if (!trySymlink(escapeTarget, linkPath, 'dir')) {
|
|
fs.rmSync(escapeTarget, { recursive: true, force: true });
|
|
return;
|
|
}
|
|
|
|
expect(() =>
|
|
migrateLegacyDataDirSync({ legacyDir, dataDir, logger: makeLogger() }),
|
|
).toThrowError(LegacyMigrationError);
|
|
|
|
// No .od-migrate-* sibling left behind.
|
|
const parent = path.dirname(dataDir);
|
|
const base = path.basename(dataDir);
|
|
const leftovers = fs
|
|
.readdirSync(parent)
|
|
.filter((entry) => entry.startsWith(`${base}.od-migrate-`));
|
|
expect(leftovers).toEqual([]);
|
|
|
|
fs.rmSync(escapeTarget, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rolls back already-promoted entries when promotion fails mid-loop', () => {
|
|
// Drives promoteStaged directly so we can inject a real filesystem
|
|
// failure mid-loop without mocking the frozen node:fs namespace.
|
|
// Without the rollback, the next boot would see a lone app.sqlite,
|
|
// dataDirHasExistingPayload would return ['app.sqlite'], and the
|
|
// migrator would refuse the retry under data_dir_not_empty, stranding
|
|
// the user mid-migration.
|
|
const stagingDir = path.join(
|
|
path.dirname(dataDir),
|
|
`${path.basename(dataDir)}.od-migrate-rollback-test`,
|
|
);
|
|
fs.mkdirSync(stagingDir, { recursive: true });
|
|
writeFile(path.join(stagingDir, 'app.sqlite'), 'staged-sqlite');
|
|
writeFile(path.join(stagingDir, 'app-config.json'), 'staged-config');
|
|
|
|
// Pre-create dataDir/app-config.json as a non-empty directory.
|
|
// renameSync of a regular file onto a non-empty directory fails
|
|
// with EISDIR/ENOTEMPTY on POSIX and EPERM on Windows; the cpSync
|
|
// fallback (file -> existing dir) likewise refuses to overlay.
|
|
// That is a real failure injection, not a mock.
|
|
fs.mkdirSync(path.join(dataDir, 'app-config.json'), { recursive: true });
|
|
writeFile(path.join(dataDir, 'app-config.json', 'block.txt'), 'occupied');
|
|
|
|
let captured: unknown;
|
|
try {
|
|
promoteStaged(stagingDir, dataDir, ['app.sqlite', 'app-config.json']);
|
|
} catch (err) {
|
|
captured = err;
|
|
}
|
|
expect(captured).toBeInstanceOf(Error);
|
|
|
|
// app.sqlite was promoted on the first iteration. The rollback
|
|
// must have removed it before rethrowing, so the user's retry
|
|
// does not get blocked under data_dir_not_empty. Stronger check:
|
|
// dataDir is free of *every* payload entry, which also catches
|
|
// the cpSync-fallback partial-write scenario (rename throws,
|
|
// cpSync starts writing dst, fails; the dst-cleanup inside
|
|
// promoteStaged plus the outer rollback together must leave a
|
|
// clean dataDir).
|
|
expect(dataDirHasExistingPayload(dataDir)).toEqual([]);
|
|
|
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rolls back promoted payload when writeMarker fails after promotion', () => {
|
|
// mrcfps round-4: writeMarker is the last step that can fail
|
|
// (ENOSPC, read-only target, permissions). If it does, payload
|
|
// entries are already in dataDir but no marker was written; the
|
|
// next boot would hit dataDirHasExistingPayload and throw
|
|
// data_dir_not_empty, making the migration un-retryable.
|
|
seedLegacyDir(legacyDir);
|
|
|
|
let captured: unknown;
|
|
try {
|
|
migrateLegacyDataDirSync({
|
|
legacyDir,
|
|
dataDir,
|
|
logger: makeLogger(),
|
|
// Simulate the worst case: writeMarker has already written a
|
|
// partial .migrated-from file before the underlying syscall
|
|
// fails. Without defensive cleanup the next boot's
|
|
// fs.existsSync(markerPath) check would short-circuit as
|
|
// `skipped` and the user would be stranded.
|
|
writeMarker: (markerDataDir) => {
|
|
fs.writeFileSync(
|
|
path.join(markerDataDir, '.migrated-from'),
|
|
'partial bytes before crash',
|
|
'utf8',
|
|
);
|
|
const e = new Error('synthetic ENOSPC writing marker') as NodeJS.ErrnoException;
|
|
e.code = 'ENOSPC';
|
|
throw e;
|
|
},
|
|
});
|
|
} catch (err) {
|
|
captured = err;
|
|
}
|
|
expect(captured).toBeInstanceOf(Error);
|
|
|
|
// Every payload entry that promoteStaged placed into dataDir must
|
|
// be gone so the next boot is not refused under data_dir_not_empty.
|
|
expect(dataDirHasExistingPayload(dataDir)).toEqual([]);
|
|
// And the partial marker the seam wrote must have been cleaned up,
|
|
// so the idempotency check on the next boot does not falsely
|
|
// short-circuit and strand the user.
|
|
expect(fs.existsSync(path.join(dataDir, '.migrated-from'))).toBe(false);
|
|
|
|
// Once the underlying disk/permissions issue is fixed, a clean
|
|
// retry without the failure injection actually succeeds.
|
|
const result = migrateLegacyDataDirSync({
|
|
legacyDir,
|
|
dataDir,
|
|
logger: makeLogger(),
|
|
});
|
|
expect(result.status).toBe('migrated');
|
|
expect(fs.existsSync(path.join(dataDir, 'app.sqlite'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dataDir, '.migrated-from'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('dataDirIsEmptyOrFresh', () => {
|
|
let dataDir: string;
|
|
beforeEach(() => {
|
|
dataDir = mkdtempSync(path.join(os.tmpdir(), 'od-fresh-'));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('treats a missing directory as fresh', async () => {
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
expect(dataDirIsEmptyOrFresh(dataDir)).toBe(true);
|
|
});
|
|
|
|
it('treats an empty directory as fresh', () => {
|
|
expect(dataDirIsEmptyOrFresh(dataDir)).toBe(true);
|
|
});
|
|
|
|
it('treats a directory with arbitrary OS scratch but no app.sqlite as fresh', () => {
|
|
writeFile(path.join(dataDir, 'lockfile'), '');
|
|
expect(dataDirIsEmptyOrFresh(dataDir)).toBe(true);
|
|
});
|
|
|
|
it('treats a directory with app.sqlite as not fresh', () => {
|
|
writeFile(path.join(dataDir, 'app.sqlite'), 'real-data');
|
|
expect(dataDirIsEmptyOrFresh(dataDir)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('legacyDirHasPayload', () => {
|
|
let legacyDir: string;
|
|
beforeEach(() => {
|
|
legacyDir = mkdtempSync(path.join(os.tmpdir(), 'od-legacy-prove-'));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(legacyDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns false when the directory is missing', async () => {
|
|
await rm(legacyDir, { recursive: true, force: true });
|
|
expect(legacyDirHasPayload(legacyDir)).toBe(false);
|
|
});
|
|
|
|
it('returns false when the directory exists but has no app.sqlite', () => {
|
|
writeFile(path.join(legacyDir, 'README.md'), 'unrelated');
|
|
expect(legacyDirHasPayload(legacyDir)).toBe(false);
|
|
});
|
|
|
|
it('returns true when the directory contains app.sqlite', () => {
|
|
writeFile(path.join(legacyDir, 'app.sqlite'), 'real');
|
|
expect(legacyDirHasPayload(legacyDir)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('dataDirHasExistingPayload', () => {
|
|
let dataDir: string;
|
|
beforeEach(() => {
|
|
dataDir = mkdtempSync(path.join(os.tmpdir(), 'od-payload-probe-'));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns an empty list when the directory is missing', async () => {
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
expect(dataDirHasExistingPayload(dataDir)).toEqual([]);
|
|
});
|
|
|
|
it('returns an empty list when the directory holds only foreign files', () => {
|
|
writeFile(path.join(dataDir, 'unrelated.log'), 'logs');
|
|
writeFile(path.join(dataDir, '.DS_Store'), '');
|
|
expect(dataDirHasExistingPayload(dataDir)).toEqual([]);
|
|
});
|
|
|
|
it('detects app.sqlite alone', () => {
|
|
writeFile(path.join(dataDir, 'app.sqlite'), 'real');
|
|
expect(dataDirHasExistingPayload(dataDir)).toEqual(['app.sqlite']);
|
|
});
|
|
|
|
it('detects projects/ alone (a payload entry can be a directory)', () => {
|
|
writeFile(path.join(dataDir, 'projects', 'p1', 'index.html'), '<x/>');
|
|
expect(dataDirHasExistingPayload(dataDir)).toEqual(['projects']);
|
|
});
|
|
|
|
it('returns every payload entry that exists, in declared order', () => {
|
|
writeFile(path.join(dataDir, 'app.sqlite'), 'real');
|
|
writeFile(path.join(dataDir, 'media-config.json'), '{"providers":{}}');
|
|
writeFile(path.join(dataDir, 'artifacts', 'a1', 'final.html'), '<x/>');
|
|
expect(dataDirHasExistingPayload(dataDir)).toEqual([
|
|
'app.sqlite',
|
|
'media-config.json',
|
|
'artifacts',
|
|
]);
|
|
});
|
|
});
|