ic-mops 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/cli.ts +143 -3
  4. package/commands/build.ts +30 -77
  5. package/commands/check-stable.ts +4 -0
  6. package/commands/check.ts +4 -0
  7. package/commands/deployed.ts +162 -0
  8. package/commands/generate.ts +201 -0
  9. package/commands/lint.ts +6 -3
  10. package/dist/cli.js +75 -3
  11. package/dist/commands/build.d.ts +6 -0
  12. package/dist/commands/build.js +26 -52
  13. package/dist/commands/check-stable.d.ts +2 -0
  14. package/dist/commands/check-stable.js +2 -2
  15. package/dist/commands/check.d.ts +2 -0
  16. package/dist/commands/check.js +2 -1
  17. package/dist/commands/deployed.d.ts +10 -0
  18. package/dist/commands/deployed.js +97 -0
  19. package/dist/commands/generate.d.ts +6 -0
  20. package/dist/commands/generate.js +124 -0
  21. package/dist/commands/lint.d.ts +2 -0
  22. package/dist/commands/lint.js +1 -1
  23. package/dist/helpers/autofix-motoko.d.ts +2 -0
  24. package/dist/helpers/autofix-motoko.js +44 -42
  25. package/dist/helpers/migrations.d.ts +1 -1
  26. package/dist/helpers/migrations.js +8 -4
  27. package/dist/helpers/moc-args.d.ts +25 -0
  28. package/dist/helpers/moc-args.js +58 -0
  29. package/dist/package.json +6 -6
  30. package/dist/tests/check-fix-utf8.test.d.ts +1 -0
  31. package/dist/tests/check-fix-utf8.test.js +38 -0
  32. package/dist/tests/check.test.js +21 -0
  33. package/dist/tests/deployed.test.d.ts +1 -0
  34. package/dist/tests/deployed.test.js +173 -0
  35. package/dist/tests/generate.test.d.ts +1 -0
  36. package/dist/tests/generate.test.js +107 -0
  37. package/dist/tests/helpers.d.ts +8 -0
  38. package/dist/tests/helpers.js +28 -3
  39. package/dist/tests/migrate.test.js +5 -19
  40. package/dist/types.d.ts +3 -0
  41. package/helpers/autofix-motoko.ts +60 -52
  42. package/helpers/migrations.ts +8 -3
  43. package/helpers/moc-args.ts +123 -0
  44. package/package.json +6 -6
  45. package/tests/__snapshots__/check-fix-utf8.test.ts.snap +27 -0
  46. package/tests/__snapshots__/deployed.test.ts.snap +10 -0
  47. package/tests/__snapshots__/generate.test.ts.snap +39 -0
  48. package/tests/check/fix-utf8/mops.toml +5 -0
  49. package/tests/check/fix-utf8/multibyte.mo +15 -0
  50. package/tests/check-fix-utf8.test.ts +51 -0
  51. package/tests/check.test.ts +24 -0
  52. package/tests/deployed/basic/main.mo +4 -0
  53. package/tests/deployed/basic/mops.toml +8 -0
  54. package/tests/deployed/multi/bar.mo +1 -0
  55. package/tests/deployed/multi/foo.mo +1 -0
  56. package/tests/deployed/multi/mops.toml +11 -0
  57. package/tests/deployed.test.ts +249 -0
  58. package/tests/generate/basic/candid/bar.did +4 -0
  59. package/tests/generate/basic/mops.toml +12 -0
  60. package/tests/generate/basic/src/Bar.mo +5 -0
  61. package/tests/generate/basic/src/Foo.mo +5 -0
  62. package/tests/generate/error/mops.toml +8 -0
  63. package/tests/generate/error/src/Broken.mo +5 -0
  64. package/tests/generate.test.ts +140 -0
  65. package/tests/helpers.ts +31 -3
  66. package/tests/migrate.test.ts +7 -22
  67. package/types.ts +3 -0
@@ -0,0 +1,123 @@
1
+ import chalk from "chalk";
2
+ import { CanisterConfig, Config } from "../types.js";
3
+ import { cliError } from "../error.js";
4
+ import { getGlobalMocArgs, resolveConfigPath } from "../mops.js";
5
+ import { sourcesArgs } from "../commands/sources.js";
6
+ import { prepareMigrationArgs } from "./migrations.js";
7
+ import { validateCanisterArgs } from "./resolve-canisters.js";
8
+
9
+ /** Single source of truth for "how moc is invoked for canister X" — keeps
10
+ * `mops build` and `mops generate candid` in lockstep so the auto-generated
11
+ * `.did` they each produce stays subtype-compatible with the curated one
12
+ * embedded by `mops build`. */
13
+ export interface PrepareMocArgsOptions {
14
+ /** Selects the migration trim limit (`build-limit` vs `check-limit`). */
15
+ mode: "check" | "build";
16
+ /** Per-flag hints used when warning about conflicting `args` entries. */
17
+ managedFlags: Record<string, string>;
18
+ /** Command label shown in the warning, e.g. "mops build". */
19
+ commandName: string;
20
+ verbose?: boolean;
21
+ extraArgs?: string[];
22
+ }
23
+
24
+ export interface PreparedMocArgs {
25
+ motokoPath: string;
26
+ /** sources + [moc].args + migration + [build].args + [canisters.x].args + CLI extras. */
27
+ args: string[];
28
+ /** Cleans up the migration staging dir; call from a `finally`. */
29
+ cleanup: () => Promise<void>;
30
+ }
31
+
32
+ export const BUILD_MANAGED_FLAGS: Record<string, string> = {
33
+ "-o": "use [build].outputDir in mops.toml or --output flag instead",
34
+ "-c": "this flag is always set by mops build",
35
+ "--idl": "this flag is always set by mops build",
36
+ "--stable-types": "this flag is always set by mops build",
37
+ "--public-metadata": "this flag is managed by mops build",
38
+ };
39
+
40
+ export const GENERATE_CANDID_MANAGED_FLAGS: Record<string, string> = {
41
+ "-o": "use the --output flag on `mops generate candid` instead",
42
+ "-c": "this flag is incompatible with mops generate candid (would also emit .wasm)",
43
+ "--idl": "this flag is always set by mops generate candid",
44
+ "--stable-types":
45
+ "this flag is incompatible with mops generate candid (would also emit .most)",
46
+ };
47
+
48
+ export async function prepareMocArgs(
49
+ config: Config,
50
+ canister: CanisterConfig,
51
+ canisterName: string,
52
+ options: PrepareMocArgsOptions,
53
+ ): Promise<PreparedMocArgs> {
54
+ if (!canister.main) {
55
+ cliError(`No main file is specified for canister ${canisterName}`);
56
+ }
57
+ const motokoPath = resolveConfigPath(canister.main);
58
+
59
+ const migration = await prepareMigrationArgs(
60
+ canister.migrations,
61
+ canisterName,
62
+ options.mode,
63
+ options.verbose,
64
+ );
65
+
66
+ const args = [
67
+ ...(await sourcesArgs()).flat(),
68
+ ...getGlobalMocArgs(config),
69
+ ...migration.migrationArgs,
70
+ ...collectExtraArgs(
71
+ config,
72
+ canister,
73
+ canisterName,
74
+ options.managedFlags,
75
+ options.commandName,
76
+ options.extraArgs,
77
+ ),
78
+ ];
79
+
80
+ return { motokoPath, args, cleanup: migration.cleanup };
81
+ }
82
+
83
+ function collectExtraArgs(
84
+ config: Config,
85
+ canister: CanisterConfig,
86
+ canisterName: string,
87
+ managedFlags: Record<string, string>,
88
+ commandName: string,
89
+ extraArgs?: string[],
90
+ ): string[] {
91
+ const args: string[] = [];
92
+
93
+ if (config.build?.args) {
94
+ if (typeof config.build.args === "string") {
95
+ cliError(
96
+ `[build] config 'args' should be an array of strings in mops.toml config file`,
97
+ );
98
+ }
99
+ args.push(...config.build.args);
100
+ }
101
+ if (canister.args) {
102
+ validateCanisterArgs(canister, canisterName, config);
103
+ args.push(...canister.args);
104
+ }
105
+ if (extraArgs) {
106
+ args.push(...extraArgs);
107
+ }
108
+
109
+ const warned = new Set<string>();
110
+ for (const arg of args) {
111
+ const hint = managedFlags[arg];
112
+ if (hint && !warned.has(arg)) {
113
+ warned.add(arg);
114
+ console.warn(
115
+ chalk.yellow(
116
+ `Warning: '${arg}' in args for canister ${canisterName} may conflict with ${commandName} — ${hint}`,
117
+ ),
118
+ );
119
+ }
120
+ }
121
+
122
+ return args;
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
@@ -76,7 +76,7 @@
76
76
  "markdown-table": "3.0.4",
77
77
  "mdast-util-from-markdown": "2.0.2",
78
78
  "mdast-util-to-markdown": "2.1.2",
79
- "minimatch": "10.2.4",
79
+ "minimatch": "10.2.5",
80
80
  "ncp": "2.0.0",
81
81
  "octokit": "3.1.2",
82
82
  "pem-file": "1.0.1",
@@ -90,7 +90,7 @@
90
90
  "semver": "7.7.1",
91
91
  "stream-to-promise": "3.0.0",
92
92
  "string-width": "7.2.0",
93
- "tar": "7.5.11",
93
+ "tar": "7.5.16",
94
94
  "terminal-size": "4.0.0",
95
95
  "vscode-languageserver-textdocument": "1.0.12"
96
96
  },
@@ -106,13 +106,13 @@
106
106
  "@types/proper-lockfile": "4.1.4",
107
107
  "@types/semver": "7.5.8",
108
108
  "@types/stream-to-promise": "2.2.4",
109
- "@types/tar": "6.1.13",
110
- "esbuild": "0.23.1",
109
+ "@types/tar": "7.0.87",
110
+ "esbuild": "0.28.1",
111
111
  "eslint": "8.57.0",
112
112
  "npm-run-all": "4.1.5",
113
113
  "rexreplace": "7.1.13",
114
114
  "ts-jest": "29.4.5",
115
- "tsx": "4.19.2",
115
+ "tsx": "4.22.4",
116
116
  "typescript": "5.9.2",
117
117
  "wasm-pack": "0.15.0"
118
118
  }
@@ -0,0 +1,27 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`check --fix (utf-8 source) multi-byte characters do not corrupt the fix: fix output 1`] = `
4
+ "Fixed run/multibyte.mo (3 fixes: M0236)
5
+
6
+ ✓ 3 fixes applied to 1 file
7
+ ✓ <TEST_DIR>/check/fix-utf8/run/multibyte.mo"
8
+ `;
9
+
10
+ exports[`check --fix (utf-8 source) multi-byte characters do not corrupt the fix: fixed file 1`] = `
11
+ "// Regression fixture: --fix must apply byte-accurately when a multi-byte UTF-8
12
+ // literal sits before the dot-notation span on the SAME line — that's where
13
+ // moc's byte columns and LSP's UTF-16 positions diverge. Each body is a single
14
+ // expression so prettier keeps it on one line (it splits \`;\`-separated
15
+ // statements). \`Char.toNat32(c)\` triggers M0236; a column-based edit over-deletes
16
+ // past the multi-byte prefix and drops the trailing \`)\`. Literal receivers are
17
+ // avoided on purpose — moc no longer suggests dot-rewrites for them
18
+ // (caffeinelabs/motoko#6173).
19
+ import Char "mo:core/Char";
20
+
21
+ module {
22
+ public func a(c : Char) : Text = "A" # debug_show (c.toNat32());
23
+ public func b(c : Char) : Text = "京" # debug_show (c.toNat32());
24
+ public func d(c : Char) : Text = "💩" # debug_show (c.toNat32());
25
+ };
26
+ "
27
+ `;
@@ -0,0 +1,10 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`deployed init creates baseline .most and sets [check-stable].path 1`] = `
4
+ {
5
+ "exitCode": 0,
6
+ "stderr": "",
7
+ "stdout": "✓ Created baseline deployed/backend.most
8
+ ✓ Set [canisters.backend.check-stable].path = "deployed/backend.most"",
9
+ }
10
+ `;
@@ -0,0 +1,39 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`generate candid configured path: overwrites in place and does not touch mops.toml 1`] = `
4
+ {
5
+ "exitCode": 0,
6
+ "stderr": "",
7
+ "stdout": "generate candid bar → candid/bar.did
8
+
9
+ ✓ Generated Candid for 1 canister",
10
+ }
11
+ `;
12
+
13
+ exports[`generate candid default path: writes <main-dir>/<name>.did and sets [canisters.<name>].candid 1`] = `
14
+ {
15
+ "exitCode": 0,
16
+ "stderr": "",
17
+ "stdout": "generate candid foo → src/foo.did
18
+
19
+ ✓ Generated Candid for 1 canister",
20
+ }
21
+ `;
22
+
23
+ exports[`generate candid default path: writes <main-dir>/<name>.did and sets [canisters.<name>].candid: src/foo.did 1`] = `
24
+ "service : {
25
+ call: () -> (text);
26
+ }
27
+ "
28
+ `;
29
+
30
+ exports[`generate candid no canister names: processes all canisters 1`] = `
31
+ {
32
+ "exitCode": 0,
33
+ "stderr": "",
34
+ "stdout": "generate candid foo → src/foo.did
35
+ generate candid bar → candid/bar.did
36
+
37
+ ✓ Generated Candid for 2 canisters",
38
+ }
39
+ `;
@@ -0,0 +1,5 @@
1
+ [dependencies]
2
+ core = "2.0.0"
3
+
4
+ [toolchain]
5
+ moc = "1.10.0"
@@ -0,0 +1,15 @@
1
+ // Regression fixture: --fix must apply byte-accurately when a multi-byte UTF-8
2
+ // literal sits before the dot-notation span on the SAME line — that's where
3
+ // moc's byte columns and LSP's UTF-16 positions diverge. Each body is a single
4
+ // expression so prettier keeps it on one line (it splits `;`-separated
5
+ // statements). `Char.toNat32(c)` triggers M0236; a column-based edit over-deletes
6
+ // past the multi-byte prefix and drops the trailing `)`. Literal receivers are
7
+ // avoided on purpose — moc no longer suggests dot-rewrites for them
8
+ // (caffeinelabs/motoko#6173).
9
+ import Char "mo:core/Char";
10
+
11
+ module {
12
+ public func a(c : Char) : Text = "A" # debug_show (Char.toNat32(c));
13
+ public func b(c : Char) : Text = "京" # debug_show (Char.toNat32(c));
14
+ public func d(c : Char) : Text = "💩" # debug_show (Char.toNat32(c));
15
+ };
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test, beforeAll } from "@jest/globals";
2
+ import { cpSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
3
+ import path from "path";
4
+ import { cli, normalizePaths } from "./helpers";
5
+
6
+ // Regression: --fix must apply byte-accurately on UTF-8 source. The fixture
7
+ // pins a moc version that emits `byte_start`/`byte_end` so the byte path is
8
+ // exercised; without those fields the fixer over-deletes on multi-byte lines
9
+ // (e.g. `Char.toNat32(c)` after a `"京"` on the same line losing its `)`).
10
+ describe("check --fix (utf-8 source)", () => {
11
+ const fixDir = path.join(import.meta.dirname, "check/fix-utf8");
12
+ const runDir = path.join(fixDir, "run");
13
+ const warningFlags = "-W=M0236";
14
+
15
+ beforeAll(() => {
16
+ for (const file of readdirSync(runDir).filter((f) => f.endsWith(".mo"))) {
17
+ unlinkSync(path.join(runDir, file));
18
+ }
19
+ });
20
+
21
+ test("multi-byte characters do not corrupt the fix", async () => {
22
+ const src = "multibyte.mo";
23
+ const runFilePath = path.join(runDir, src);
24
+ cpSync(path.join(fixDir, src), runFilePath);
25
+
26
+ const fixResult = await cli(
27
+ ["check", runFilePath, "--fix", "--", warningFlags],
28
+ { cwd: fixDir },
29
+ );
30
+
31
+ expect(normalizePaths(fixResult.stdout)).toMatchSnapshot("fix output");
32
+ const fixedContent = readFileSync(runFilePath, "utf-8");
33
+ expect(fixedContent).toMatchSnapshot("fixed file");
34
+
35
+ // Concrete byte-accuracy assertions: every Char.toNat32 call must be
36
+ // rewritten to dot notation, with the multi-byte literal before it intact
37
+ // and the trailing `)` preserved (the original regression: 京/💩 used to
38
+ // drop it).
39
+ expect(fixedContent).toContain('"A" # debug_show (c.toNat32());');
40
+ expect(fixedContent).toContain('"京" # debug_show (c.toNat32());');
41
+ expect(fixedContent).toContain('"💩" # debug_show (c.toNat32());');
42
+ expect(fixedContent).not.toMatch(/debug_show \(Char\.toNat32/);
43
+
44
+ // Verify no remaining M0236 warnings.
45
+ const afterResult = await cli(
46
+ ["check", runFilePath, "--", warningFlags, "--error-format=json"],
47
+ { cwd: fixDir },
48
+ );
49
+ expect(afterResult.stdout).not.toContain('"code":"M0236"');
50
+ });
51
+ });
@@ -171,4 +171,28 @@ describe("check", () => {
171
171
  expect(result.exitCode).toBe(0);
172
172
  expect(result.stdout).toMatch(/✓ Lint fixes applied/);
173
173
  });
174
+
175
+ // The migrations-chain fixture has 4 migrations with check-limit=3, so the
176
+ // oldest is trimmed by default (staged dir + M0254 suppression). --no-check-limit
177
+ // must point moc at the real chain dir and drop the trimming side effects.
178
+ const migrationsChain = "check-stable/migrations-chain";
179
+
180
+ test("check-limit trims the migration chain by default", async () => {
181
+ const cwd = path.join(import.meta.dirname, migrationsChain);
182
+ const result = await cli(["check", "--verbose"], { cwd });
183
+ expect(result.exitCode).toBe(0);
184
+ expect(result.stdout).toMatch(/--enhanced-migration=[^"]*\.migrations-/);
185
+ expect(result.stdout).toMatch(/-A=M0254/);
186
+ });
187
+
188
+ test("--no-check-limit uses the full migration chain", async () => {
189
+ const cwd = path.join(import.meta.dirname, migrationsChain);
190
+ const result = await cli(["check", "--verbose", "--no-check-limit"], {
191
+ cwd,
192
+ });
193
+ expect(result.exitCode).toBe(0);
194
+ expect(result.stdout).toMatch(/--enhanced-migration=/);
195
+ expect(result.stdout).not.toMatch(/\.migrations-/);
196
+ expect(result.stdout).not.toMatch(/-A=M0254/);
197
+ });
174
198
  });
@@ -0,0 +1,4 @@
1
+ actor {
2
+ stable var counter : Nat = 0;
3
+ stable var name : Text = "";
4
+ };
@@ -0,0 +1,8 @@
1
+ [toolchain]
2
+ moc = "1.5.0"
3
+
4
+ [moc]
5
+ args = ["--default-persistent-actors"]
6
+
7
+ [canisters.backend]
8
+ main = "main.mo"
@@ -0,0 +1 @@
1
+ actor { stable var y : Text = "" };
@@ -0,0 +1 @@
1
+ actor { stable var x : Nat = 0 };
@@ -0,0 +1,11 @@
1
+ [toolchain]
2
+ moc = "1.5.0"
3
+
4
+ [moc]
5
+ args = ["--default-persistent-actors"]
6
+
7
+ [canisters.foo]
8
+ main = "foo.mo"
9
+
10
+ [canisters.bar]
11
+ main = "bar.mo"
@@ -0,0 +1,249 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { cli, normalizePaths, useTempFixtures } from "./helpers";
6
+
7
+ describe("deployed", () => {
8
+ const makeTempFixture = useTempFixtures(
9
+ path.join(import.meta.dirname, "deployed"),
10
+ );
11
+
12
+ describe("init", () => {
13
+ test("creates baseline .most and sets [check-stable].path", async () => {
14
+ const cwd = await makeTempFixture("basic");
15
+ const result = await cli(["deployed", "init"], { cwd });
16
+ expect(result.exitCode).toBe(0);
17
+
18
+ const baseline = path.join(cwd, "deployed", "backend.most");
19
+ expect(existsSync(baseline)).toBe(true);
20
+ expect(readFileSync(baseline, "utf-8")).toBe(
21
+ "// Version: 1.0.0\nactor { };\n",
22
+ );
23
+
24
+ const toml = readFileSync(path.join(cwd, "mops.toml"), "utf-8");
25
+ expect(toml).toMatch(/\[canisters\.backend\.check-stable\]/);
26
+ expect(toml).toMatch(/path\s*=\s*"deployed\/backend\.most"/);
27
+
28
+ expect({
29
+ exitCode: result.exitCode,
30
+ stdout: normalizePaths(result.stdout),
31
+ stderr: normalizePaths(result.stderr),
32
+ }).toMatchSnapshot();
33
+ });
34
+
35
+ test("idempotent — second run is a no-op", async () => {
36
+ const cwd = await makeTempFixture("basic");
37
+ await cli(["deployed", "init"], { cwd });
38
+ const tomlBefore = readFileSync(path.join(cwd, "mops.toml"), "utf-8");
39
+
40
+ const result = await cli(["deployed", "init"], { cwd });
41
+ expect(result.exitCode).toBe(0);
42
+
43
+ const tomlAfter = readFileSync(path.join(cwd, "mops.toml"), "utf-8");
44
+ expect(tomlAfter).toBe(tomlBefore);
45
+ });
46
+
47
+ test("warns when [check-stable].path is set elsewhere", async () => {
48
+ const cwd = await makeTempFixture("basic");
49
+ const tomlPath = path.join(cwd, "mops.toml");
50
+ const toml = readFileSync(tomlPath, "utf-8");
51
+ await writeFile(
52
+ tomlPath,
53
+ toml +
54
+ '\n[canisters.backend.check-stable]\npath = "elsewhere/backend.most"\n',
55
+ );
56
+
57
+ const result = await cli(["deployed", "init"], { cwd });
58
+ expect(result.exitCode).toBe(0);
59
+ expect(result.stderr).toMatch(
60
+ /check-stable\]\.path = "elsewhere\/backend\.most"/,
61
+ );
62
+ expect(result.stderr).toMatch(/does not match where `mops deployed`/);
63
+ expect(existsSync(path.join(cwd, "deployed", "backend.most"))).toBe(true);
64
+
65
+ // mops.toml should not have been rewritten
66
+ expect(readFileSync(tomlPath, "utf-8")).toBe(
67
+ toml +
68
+ '\n[canisters.backend.check-stable]\npath = "elsewhere/backend.most"\n',
69
+ );
70
+ });
71
+
72
+ test("respects [deployed].dir", async () => {
73
+ const cwd = await makeTempFixture("basic");
74
+ const tomlPath = path.join(cwd, "mops.toml");
75
+ const toml = readFileSync(tomlPath, "utf-8");
76
+ await writeFile(tomlPath, `${toml}\n[deployed]\ndir = "snapshots"\n`);
77
+
78
+ const result = await cli(["deployed", "init"], { cwd });
79
+ expect(result.exitCode).toBe(0);
80
+ expect(existsSync(path.join(cwd, "snapshots", "backend.most"))).toBe(
81
+ true,
82
+ );
83
+ expect(readFileSync(tomlPath, "utf-8")).toMatch(
84
+ /path\s*=\s*"snapshots\/backend\.most"/,
85
+ );
86
+ });
87
+
88
+ test("init for shorthand canister entry promotes it to a table", async () => {
89
+ const cwd = await makeTempFixture("basic");
90
+ const tomlPath = path.join(cwd, "mops.toml");
91
+ await writeFile(
92
+ tomlPath,
93
+ '[toolchain]\nmoc = "1.5.0"\n\n[canisters]\nbackend = "main.mo"\n',
94
+ );
95
+
96
+ const result = await cli(["deployed", "init"], { cwd });
97
+ expect(result.exitCode).toBe(0);
98
+
99
+ const after = readFileSync(tomlPath, "utf-8");
100
+ expect(after).toMatch(/\[canisters\.backend\]/);
101
+ expect(after).toMatch(/main\s*=\s*"main\.mo"/);
102
+ expect(after).toMatch(/\[canisters\.backend\.check-stable\]/);
103
+ expect(after).toMatch(/path\s*=\s*"deployed\/backend\.most"/);
104
+ });
105
+
106
+ test("errors on unknown canister name", async () => {
107
+ const cwd = await makeTempFixture("basic");
108
+ const result = await cli(["deployed", "init", "ghost"], { cwd });
109
+ expect(result.exitCode).toBe(1);
110
+ expect(result.stderr).toMatch(/not found in mops\.toml/);
111
+ });
112
+
113
+ test("preserves an existing baseline file when wiring [check-stable].path", async () => {
114
+ const cwd = await makeTempFixture("basic");
115
+ const baseline = path.join(cwd, "deployed", "backend.most");
116
+ const customMost =
117
+ "// Version: 1.0.0\nactor {\n stable var x : Nat\n};\n";
118
+ // pre-create the baseline (e.g. real prod snapshot copied in by the user)
119
+ await mkdir(path.join(cwd, "deployed"), { recursive: true });
120
+ await writeFile(baseline, customMost);
121
+
122
+ const result = await cli(["deployed", "init"], { cwd });
123
+ expect(result.exitCode).toBe(0);
124
+ expect(readFileSync(baseline, "utf-8")).toBe(customMost);
125
+ expect(readFileSync(path.join(cwd, "mops.toml"), "utf-8")).toMatch(
126
+ /\[canisters\.backend\.check-stable\][\s\S]*path\s*=\s*"deployed\/backend\.most"/,
127
+ );
128
+ });
129
+ });
130
+
131
+ describe("post-deploy hook", () => {
132
+ test("copies built .most into deployed/", async () => {
133
+ const cwd = await makeTempFixture("basic");
134
+ const buildResult = await cli(["build"], { cwd });
135
+ expect(buildResult.exitCode).toBe(0);
136
+
137
+ const result = await cli(["deployed"], { cwd });
138
+ expect(result.exitCode).toBe(0);
139
+
140
+ const built = path.join(cwd, ".mops", ".build", "backend.most");
141
+ const promoted = path.join(cwd, "deployed", "backend.most");
142
+ expect(existsSync(promoted)).toBe(true);
143
+ expect(readFileSync(promoted, "utf-8")).toBe(
144
+ readFileSync(built, "utf-8"),
145
+ );
146
+ });
147
+
148
+ test("errors when source .most is missing", async () => {
149
+ const cwd = await makeTempFixture("basic");
150
+ const result = await cli(["deployed", "backend"], { cwd });
151
+ expect(result.exitCode).toBe(1);
152
+ expect(result.stderr).toMatch(/No built \.most/);
153
+ expect(result.stderr).toMatch(/Run `mops build backend` first/);
154
+ });
155
+
156
+ test("warns when [check-stable].path differs from <dir>/<name>.most", async () => {
157
+ const cwd = await makeTempFixture("basic");
158
+ const tomlPath = path.join(cwd, "mops.toml");
159
+ const toml = readFileSync(tomlPath, "utf-8");
160
+ await writeFile(
161
+ tomlPath,
162
+ toml +
163
+ '\n[canisters.backend.check-stable]\npath = "elsewhere/backend.most"\n',
164
+ );
165
+
166
+ const buildResult = await cli(["build"], { cwd });
167
+ expect(buildResult.exitCode).toBe(0);
168
+
169
+ const result = await cli(["deployed"], { cwd });
170
+ expect(result.exitCode).toBe(0);
171
+ expect(result.stderr).toMatch(
172
+ /check-stable\]\.path = "elsewhere\/backend\.most"/,
173
+ );
174
+ expect(result.stderr).toMatch(/won't see updates from `mops deployed`/);
175
+ });
176
+
177
+ test("respects [deployed].dir from config", async () => {
178
+ const cwd = await makeTempFixture("basic");
179
+ const tomlPath = path.join(cwd, "mops.toml");
180
+ const toml = readFileSync(tomlPath, "utf-8");
181
+ await writeFile(tomlPath, `${toml}\n[deployed]\ndir = "snapshots"\n`);
182
+
183
+ await cli(["build"], { cwd });
184
+ const result = await cli(["deployed"], { cwd });
185
+ expect(result.exitCode).toBe(0);
186
+ expect(existsSync(path.join(cwd, "snapshots", "backend.most"))).toBe(
187
+ true,
188
+ );
189
+ expect(existsSync(path.join(cwd, "deployed", "backend.most"))).toBe(
190
+ false,
191
+ );
192
+ });
193
+
194
+ test("--build-dir and --dir overrides", async () => {
195
+ const cwd = await makeTempFixture("basic");
196
+ const customOut = "custom-build";
197
+ const customDir = "snapshots";
198
+
199
+ const buildResult = await cli(
200
+ ["build", "backend", "--output", customOut],
201
+ { cwd },
202
+ );
203
+ expect(buildResult.exitCode).toBe(0);
204
+
205
+ const result = await cli(
206
+ ["deployed", "--build-dir", customOut, "--dir", customDir],
207
+ { cwd },
208
+ );
209
+ expect(result.exitCode).toBe(0);
210
+ expect(existsSync(path.join(cwd, customDir, "backend.most"))).toBe(true);
211
+ });
212
+
213
+ test("with no canister names promotes all canisters", async () => {
214
+ const cwd = await makeTempFixture("multi");
215
+ const buildResult = await cli(["build"], { cwd });
216
+ expect(buildResult.exitCode).toBe(0);
217
+
218
+ const result = await cli(["deployed"], { cwd });
219
+ expect(result.exitCode).toBe(0);
220
+ expect(existsSync(path.join(cwd, "deployed", "foo.most"))).toBe(true);
221
+ expect(existsSync(path.join(cwd, "deployed", "bar.most"))).toBe(true);
222
+ });
223
+
224
+ test("named canister filters to that canister only", async () => {
225
+ const cwd = await makeTempFixture("multi");
226
+ await cli(["build"], { cwd });
227
+
228
+ const result = await cli(["deployed", "foo"], { cwd });
229
+ expect(result.exitCode).toBe(0);
230
+ expect(existsSync(path.join(cwd, "deployed", "foo.most"))).toBe(true);
231
+ expect(existsSync(path.join(cwd, "deployed", "bar.most"))).toBe(false);
232
+ });
233
+ });
234
+
235
+ describe("end-to-end with check-stable", () => {
236
+ test("init + build + deployed wires the check-stable baseline", async () => {
237
+ const cwd = await makeTempFixture("basic");
238
+
239
+ await cli(["deployed", "init"], { cwd });
240
+ await cli(["build"], { cwd });
241
+ const promoteResult = await cli(["deployed"], { cwd });
242
+ expect(promoteResult.exitCode).toBe(0);
243
+
244
+ const checkResult = await cli(["check-stable"], { cwd });
245
+ expect(checkResult.exitCode).toBe(0);
246
+ expect(checkResult.stdout).toMatch(/Stable compatibility check passed/);
247
+ });
248
+ });
249
+ });
@@ -0,0 +1,4 @@
1
+ // Hand-curated; will be overwritten by `mops generate candid bar`.
2
+ service : {
3
+ greet: (text) -> (text);
4
+ }
@@ -0,0 +1,12 @@
1
+ [toolchain]
2
+ moc = "1.5.0"
3
+
4
+ [moc]
5
+ args = ["--default-persistent-actors"]
6
+
7
+ [canisters.foo]
8
+ main = "src/Foo.mo"
9
+
10
+ [canisters.bar]
11
+ main = "src/Bar.mo"
12
+ candid = "candid/bar.did"
@@ -0,0 +1,5 @@
1
+ persistent actor {
2
+ public func greet(name : Text) : async Text {
3
+ "Hello, " # name;
4
+ };
5
+ };
@@ -0,0 +1,5 @@
1
+ persistent actor {
2
+ public func call() : async Text {
3
+ "Foo";
4
+ };
5
+ };
@@ -0,0 +1,8 @@
1
+ [toolchain]
2
+ moc = "1.5.0"
3
+
4
+ [moc]
5
+ args = ["--default-persistent-actors"]
6
+
7
+ [canisters.broken]
8
+ main = "src/Broken.mo"