| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { expandMatrix, MatrixError } from "../src/index.js"; |
| 3 | |
| 4 | describe("expandMatrix", () => { |
| 5 | it("no matrix yields one empty combination", () => { |
| 6 | expect(expandMatrix(undefined)).toEqual([{}]); |
| 7 | expect(expandMatrix(null)).toEqual([{}]); |
| 8 | }); |
| 9 | |
| 10 | it("single dimension", () => { |
| 11 | expect(expandMatrix({ version: [10, 12, 14] })).toEqual([{ version: 10 }, { version: 12 }, { version: 14 }]); |
| 12 | }); |
| 13 | |
| 14 | it("cartesian product with first key outermost", () => { |
| 15 | expect(expandMatrix({ os: ["ubuntu-latest", "windows-latest"], version: [10, 12, 14] })).toEqual([ |
| 16 | { os: "ubuntu-latest", version: 10 }, |
| 17 | { os: "ubuntu-latest", version: 12 }, |
| 18 | { os: "ubuntu-latest", version: 14 }, |
| 19 | { os: "windows-latest", version: 10 }, |
| 20 | { os: "windows-latest", version: 12 }, |
| 21 | { os: "windows-latest", version: 14 }, |
| 22 | ]); |
| 23 | }); |
| 24 | |
| 25 | it("include example from the GitHub docs", () => { |
| 26 | const out = expandMatrix({ |
| 27 | fruit: ["apple", "pear"], |
| 28 | animal: ["cat", "dog"], |
| 29 | include: [ |
| 30 | { color: "green" }, |
| 31 | { color: "pink", animal: "cat" }, |
| 32 | { fruit: "apple", shape: "circle" }, |
| 33 | { fruit: "banana" }, |
| 34 | { fruit: "banana", animal: "cat" }, |
| 35 | ], |
| 36 | }); |
| 37 | expect(out).toEqual([ |
| 38 | { fruit: "apple", animal: "cat", color: "pink", shape: "circle" }, |
| 39 | { fruit: "apple", animal: "dog", color: "green", shape: "circle" }, |
| 40 | { fruit: "pear", animal: "cat", color: "pink" }, |
| 41 | { fruit: "pear", animal: "dog", color: "green" }, |
| 42 | { fruit: "banana" }, |
| 43 | { fruit: "banana", animal: "cat" }, |
| 44 | ]); |
| 45 | }); |
| 46 | |
| 47 | it("expanding configurations example from the docs", () => { |
| 48 | const out = expandMatrix({ |
| 49 | os: ["windows-latest", "ubuntu-latest"], |
| 50 | node: [14, 16], |
| 51 | include: [{ os: "windows-latest", node: 16, npm: 6 }], |
| 52 | }); |
| 53 | expect(out).toEqual([ |
| 54 | { os: "windows-latest", node: 14 }, |
| 55 | { os: "windows-latest", node: 16, npm: 6 }, |
| 56 | { os: "ubuntu-latest", node: 14 }, |
| 57 | { os: "ubuntu-latest", node: 16 }, |
| 58 | ]); |
| 59 | }); |
| 60 | |
| 61 | it("include-only matrix", () => { |
| 62 | expect( |
| 63 | expandMatrix({ |
| 64 | include: [ |
| 65 | { site: "production", datacenter: "site-a" }, |
| 66 | { site: "staging", datacenter: "site-b" }, |
| 67 | ], |
| 68 | }), |
| 69 | ).toEqual([ |
| 70 | { site: "production", datacenter: "site-a" }, |
| 71 | { site: "staging", datacenter: "site-b" }, |
| 72 | ]); |
| 73 | }); |
| 74 | |
| 75 | it("exclude with partial match (docs example)", () => { |
| 76 | const out = expandMatrix({ |
| 77 | os: ["macos-latest", "windows-latest"], |
| 78 | version: [12, 14, 16], |
| 79 | environment: ["staging", "production"], |
| 80 | exclude: [ |
| 81 | { os: "macos-latest", version: 12, environment: "production" }, |
| 82 | { os: "windows-latest", version: 16 }, |
| 83 | ], |
| 84 | }); |
| 85 | expect(out).toHaveLength(12 - 1 - 2); |
| 86 | expect(out).not.toContainEqual({ os: "macos-latest", version: 12, environment: "production" }); |
| 87 | expect(out.filter((c) => c.os === "windows-latest" && c.version === 16)).toEqual([]); |
| 88 | expect(out).toContainEqual({ os: "macos-latest", version: 12, environment: "staging" }); |
| 89 | }); |
| 90 | |
| 91 | it("exclude is applied before include, and include can re-add", () => { |
| 92 | const out = expandMatrix({ |
| 93 | os: ["linux", "mac"], |
| 94 | exclude: [{ os: "mac" }], |
| 95 | include: [{ os: "mac", experimental: true }], |
| 96 | }); |
| 97 | expect(out).toEqual([{ os: "linux" }, { os: "mac", experimental: true }]); |
| 98 | }); |
| 99 | |
| 100 | it("exclude everything leaves no combinations", () => { |
| 101 | expect(expandMatrix({ os: ["a"], exclude: [{ os: "a" }] })).toEqual([]); |
| 102 | }); |
| 103 | |
| 104 | it("object-valued vectors and partial object exclude", () => { |
| 105 | const out = expandMatrix({ |
| 106 | cfg: [ |
| 107 | { name: "a", flags: "-O2" }, |
| 108 | { name: "b", flags: "-O0" }, |
| 109 | ], |
| 110 | arch: ["x64", "arm64"], |
| 111 | exclude: [{ cfg: { name: "b" }, arch: "arm64" }], |
| 112 | }); |
| 113 | expect(out).toEqual([ |
| 114 | { cfg: { name: "a", flags: "-O2" }, arch: "x64" }, |
| 115 | { cfg: { name: "a", flags: "-O2" }, arch: "arm64" }, |
| 116 | { cfg: { name: "b", flags: "-O0" }, arch: "x64" }, |
| 117 | ]); |
| 118 | }); |
| 119 | |
| 120 | it("include matching values loosely ('18' vs 18)", () => { |
| 121 | expect(expandMatrix({ node: [18, 20], include: [{ node: "18", lts: true }] })).toEqual([ |
| 122 | { node: 18, lts: true }, |
| 123 | { node: 20 }, |
| 124 | ]); |
| 125 | }); |
| 126 | |
| 127 | it("evaluates a fromJSON matrix expression with ctx", () => { |
| 128 | const ctx = { needs: { setup: { outputs: { matrix: '{"pkg":["a","b"],"include":[{"pkg":"a","extra":1}]}' } } } }; |
| 129 | expect(expandMatrix("${{ fromJSON(needs.setup.outputs.matrix) }}", ctx)).toEqual([ |
| 130 | { pkg: "a", extra: 1 }, |
| 131 | { pkg: "b" }, |
| 132 | ]); |
| 133 | }); |
| 134 | |
| 135 | it("evaluates expression vectors and include lists", () => { |
| 136 | const ctx = { inputs: { versions: "[1,2]", extra: '[{"v":3}]' } }; |
| 137 | expect(expandMatrix({ v: "${{ fromJSON(inputs.versions) }}", include: "${{ fromJSON(inputs.extra) }}" }, ctx)).toEqual([ |
| 138 | { v: 1 }, |
| 139 | { v: 2 }, |
| 140 | { v: 3 }, |
| 141 | ]); |
| 142 | }); |
| 143 | |
| 144 | it("errors on invalid shapes", () => { |
| 145 | expect(() => expandMatrix({ os: "ubuntu" })).toThrow(MatrixError); |
| 146 | expect(() => expandMatrix({ include: ["x"] })).toThrow(/include\[0\]/); |
| 147 | expect(() => expandMatrix([1, 2])).toThrow(/mapping/); |
| 148 | }); |
| 149 | }); |
| 150 | |
| 151 | describe("matrix size", () => { |
| 152 | it("refuses a matrix with too many combinations before building any", () => { |
| 153 | const m: Record<string, number[]> = {}; |
| 154 | for (let i = 0; i < 6; i++) m[`k${i}`] = Array.from({ length: 20 }, (_, j) => j); |
| 155 | const start = Date.now(); |
| 156 | expect(() => expandMatrix(m)).toThrow(/64000000 combinations before exclusions; at most 4096/); |
| 157 | expect(Date.now() - start).toBeLessThan(1000); |
| 158 | }); |
| 159 | it("still allows a large product that stays under the bound", () => { |
| 160 | expect(expandMatrix({ a: Array.from({ length: 64 }, (_, i) => i), b: Array.from({ length: 64 }, (_, i) => i) })).toHaveLength(4096); |
| 161 | }); |
| 162 | it("bounds include and exclude", () => { |
| 163 | const many = Array.from({ length: 257 }, (_, i) => ({ a: i })); |
| 164 | expect(() => expandMatrix({ a: [1], include: many })).toThrow(/matrix.include has 257 entries/); |
| 165 | expect(() => expandMatrix({ a: [1], exclude: many })).toThrow(/matrix.exclude has 257 entries/); |
| 166 | }); |
| 167 | }); |