@fromeroc9/testform 1.0.3 → 1.0.4

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 (50) hide show
  1. package/dist/action/index.js +1 -1
  2. package/dist/action.js +60 -0
  3. package/dist/adapters/github.js +467 -0
  4. package/dist/adapters/resources.js +363 -0
  5. package/dist/cli/index.js +3 -3
  6. package/dist/commands/apply.js +390 -0
  7. package/dist/commands/destroy.js +85 -0
  8. package/dist/commands/diff.js +131 -0
  9. package/dist/commands/fmt.js +166 -0
  10. package/dist/commands/force-unlock.js +55 -0
  11. package/dist/commands/generate.js +143 -0
  12. package/dist/commands/graph.js +159 -0
  13. package/dist/commands/import.js +222 -0
  14. package/dist/commands/init.js +167 -0
  15. package/dist/commands/login.js +71 -0
  16. package/dist/commands/logout.js +20 -0
  17. package/dist/commands/plan.js +250 -0
  18. package/dist/commands/refresh.js +165 -0
  19. package/dist/commands/report.js +724 -0
  20. package/dist/commands/show.js +61 -0
  21. package/dist/commands/state.js +197 -0
  22. package/dist/commands/taint.js +49 -0
  23. package/dist/commands/validate.js +128 -0
  24. package/dist/commands/workspace.js +102 -0
  25. package/dist/const.js +105 -0
  26. package/dist/core/backends/azurerm.js +201 -0
  27. package/dist/core/backends/backend.js +2 -0
  28. package/dist/core/backends/gcs.js +200 -0
  29. package/dist/core/backends/local.js +162 -0
  30. package/dist/core/backends/s3.js +224 -0
  31. package/dist/core/command-context.js +59 -0
  32. package/dist/core/config.js +131 -0
  33. package/dist/core/credentials.js +53 -0
  34. package/dist/core/parser.js +62 -0
  35. package/dist/core/parsers/base-parser.js +215 -0
  36. package/dist/core/parsers/testcase-parser.js +115 -0
  37. package/dist/core/parsers/testplan-parser.js +41 -0
  38. package/dist/core/parsers/testrun-parser.js +43 -0
  39. package/dist/core/policy.js +341 -0
  40. package/dist/core/prompt.js +109 -0
  41. package/dist/core/state.js +185 -0
  42. package/dist/core/utils.js +94 -0
  43. package/dist/core/variables.js +108 -0
  44. package/dist/core/workspace.js +56 -0
  45. package/dist/help.js +797 -0
  46. package/dist/index.js +650 -0
  47. package/dist/logger.js +134 -0
  48. package/dist/notify.js +36 -0
  49. package/dist/types.js +2 -0
  50. package/package.json +1 -1
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.planCmd = void 0;
4
+ exports.hashScenario = hashScenario;
5
+ exports.calculatePlan = calculatePlan;
6
+ const crypto_1 = require("crypto");
7
+ const const_1 = require("../const");
8
+ const chalk_1 = require("chalk");
9
+ const resources_1 = require("../adapters/resources");
10
+ const config_1 = require("../core/config");
11
+ const parser_1 = require("../core/parser");
12
+ const policy_1 = require("../core/policy");
13
+ const state_1 = require("../core/state");
14
+ const logger_1 = require("../logger");
15
+ const fs_1 = require("fs");
16
+ const refresh_1 = require("./refresh");
17
+ const const_2 = require("../const");
18
+ const utils_1 = require("../core/utils");
19
+ const path_1 = require("path");
20
+ /**
21
+ * Calculate the hash for a scenario (used for idempotency).
22
+ */
23
+ function hashScenario(scenario) {
24
+ return (0, crypto_1.createHash)('sha256').update(JSON.stringify(scenario)).digest('hex');
25
+ }
26
+ /**
27
+ * Calculate a plan by comparing local .feature files against the state.
28
+ * This is a PURE READ-ONLY operation — state is never modified.
29
+ */
30
+ async function calculatePlan(options) {
31
+ const { dir, scope, variables, statePath, backupPath, target, destroyPlan = false, refreshOnly = false, preLoadedState, lock = true, lockTimeout = '0s', replaceTargets, compactWarnings, testDirectory } = options;
32
+ const config = new config_1.Config(dir);
33
+ if (!Object.prototype.hasOwnProperty.call(const_2.SCOPE_CONFIG, scope)) {
34
+ throw new Error(`Invalid scope: ${scope}`);
35
+ }
36
+ const scopeCfg = const_2.SCOPE_CONFIG[scope];
37
+ const RESOURCE_TYPE = scopeCfg.resource;
38
+ const parseDir = testDirectory ? (0, path_1.join)(dir, testDirectory) : dir;
39
+ const parser = new parser_1.Parser(parseDir, variables);
40
+ const documents = parser.content();
41
+ const matchesScope = (s, scopeName) => {
42
+ if (!Object.prototype.hasOwnProperty.call(const_2.SCOPE_CONFIG, scopeName))
43
+ return false;
44
+ const cfg = const_2.SCOPE_CONFIG[scopeName];
45
+ return s.feature?.tags?.includes(cfg.tag) || s.uri.endsWith(cfg.ext);
46
+ };
47
+ // Only process scenarios that match the requested scope
48
+ let rawScenarios = documents.filter(s => matchesScope(s, scope));
49
+ const data = {
50
+ identity: config.getIdentity(scope),
51
+ fields: config.getFields(scope),
52
+ };
53
+ let filtered = parser.filter(rawScenarios, data, scope) || [];
54
+ if (target) {
55
+ filtered = filtered.filter(s => {
56
+ const id = s.custom?.identity ? `${s.uri}::${s.custom.identity}` : '';
57
+ return id === target || id.startsWith(`${target}::`) || id.endsWith(`/${target}`) || id.endsWith(target);
58
+ });
59
+ }
60
+ if (destroyPlan) {
61
+ // If destroy, we treat local scenarios as empty
62
+ // to force destruction of everything.
63
+ filtered = [];
64
+ }
65
+ // If refresh-only, policy scanning might not be relevant for empty local state, but we already filtered it.
66
+ const hasViolations = policy_1.policy.scanner(filtered, scope, false, compactWarnings);
67
+ if (hasViolations) {
68
+ const err = new Error("Please fix them before continuing.");
69
+ err.name = "Policy violations found";
70
+ throw err;
71
+ }
72
+ // Load state (read-only)
73
+ const state = preLoadedState || new state_1.State(dir, statePath, backupPath);
74
+ if (!preLoadedState) {
75
+ await state.init();
76
+ await state.acquireLock(lock, lockTimeout);
77
+ }
78
+ let resources = state.getResources(RESOURCE_TYPE);
79
+ if (target) {
80
+ resources = resources.filter(r => r.identity === target || r.identity.startsWith(`${target}::`) || r.identity.endsWith(`/${target}`) || r.identity.endsWith(target));
81
+ }
82
+ if (refreshOnly) {
83
+ return {
84
+ changes: [],
85
+ state,
86
+ hasChanges: false,
87
+ };
88
+ }
89
+ for (const res of resources) {
90
+ // Display refreshing text
91
+ const remoteIdPart = res.attributes.remoteId ? `[id=${res.attributes.remoteId}]` : '';
92
+ console.log((0, chalk_1.bold)(`${(0, utils_1.formatResourceAddress)(res.type, res.identity)}: Refreshing state... ${remoteIdPart}`));
93
+ }
94
+ console.log("");
95
+ // Build a lookup map for O(1) access by identity
96
+ const stateMap = new Map(resources.map(r => [r.identity, r]));
97
+ // Track which identities exist locally
98
+ const localIds = new Set();
99
+ const changes = [];
100
+ // Check for creates and updates
101
+ for (const scenario of filtered) {
102
+ let identity;
103
+ const rawIdentity = scenario.custom?.identity;
104
+ if (!rawIdentity)
105
+ continue;
106
+ if (rawIdentity.includes('::')) {
107
+ identity = rawIdentity;
108
+ }
109
+ else if (rawIdentity === scenario.uri) {
110
+ identity = rawIdentity;
111
+ }
112
+ else {
113
+ identity = `${scenario.uri}::${rawIdentity}`;
114
+ }
115
+ localIds.add(identity);
116
+ const localHash = hashScenario(scenario);
117
+ const existing = stateMap.get(identity);
118
+ const shouldForceReplace = replaceTargets
119
+ ? (Array.isArray(replaceTargets)
120
+ ? replaceTargets.some(t => identity === t || identity.startsWith(`${t}::`) || identity.endsWith(`/${t}`) || identity.endsWith(t))
121
+ : (identity === replaceTargets || identity.startsWith(`${replaceTargets}::`) || identity.endsWith(`/${replaceTargets}`) || identity.endsWith(replaceTargets)))
122
+ : false;
123
+ if (!existing) {
124
+ // New resource — needs to be created
125
+ changes.push({
126
+ action: 'add',
127
+ identity,
128
+ resourceType: RESOURCE_TYPE,
129
+ scenario,
130
+ localHash,
131
+ });
132
+ }
133
+ else if (existing.tainted || shouldForceReplace) {
134
+ // Tainted or forced replace resource — needs to be replaced
135
+ changes.push({
136
+ action: 'replace',
137
+ identity,
138
+ resourceType: RESOURCE_TYPE,
139
+ scenario,
140
+ remoteId: existing.attributes.remoteId,
141
+ issueNumber: existing.attributes.issueNumber,
142
+ localHash,
143
+ oldAttributes: existing.attributes,
144
+ });
145
+ }
146
+ else if (existing.attributes.localHash !== localHash) {
147
+ // Changed resource — needs to be updated
148
+ changes.push({
149
+ action: 'change',
150
+ identity,
151
+ resourceType: RESOURCE_TYPE,
152
+ scenario,
153
+ remoteId: existing.attributes.remoteId,
154
+ issueNumber: existing.attributes.issueNumber,
155
+ localHash,
156
+ oldAttributes: existing.attributes,
157
+ });
158
+ }
159
+ // If hashes match → synced, no action needed
160
+ }
161
+ // Check for destroys (in state but not in local files)
162
+ for (const res of resources) {
163
+ if (!localIds.has(res.identity)) {
164
+ // Create a minimal scenario from state for display
165
+ changes.push({
166
+ action: 'destroy',
167
+ identity: res.identity,
168
+ resourceType: RESOURCE_TYPE,
169
+ scenario: (scope === 'testrun' || scope === 'testplan') ? {
170
+ uri: '(state)',
171
+ feature: { tags: [], keyword: '', name: res.attributes.title, description: '', location: 0 },
172
+ location: 0,
173
+ keyword: '',
174
+ name: res.attributes.title,
175
+ description: '',
176
+ steps: [],
177
+ tags: Array.isArray(res.attributes.labels) ? res.attributes.labels : (res.attributes.labels ? String(res.attributes.labels).split(',') : []),
178
+ custom: { identity: res.identity },
179
+ } : {
180
+ uri: '(state)',
181
+ feature: { tags: [], keyword: '', name: '', description: '', location: 0 },
182
+ location: 0,
183
+ keyword: '',
184
+ name: res.attributes.title,
185
+ description: '',
186
+ steps: [],
187
+ tags: res.attributes.labels,
188
+ custom: { identity: res.identity },
189
+ },
190
+ remoteId: res.attributes.remoteId,
191
+ issueNumber: res.attributes.issueNumber,
192
+ localHash: res.attributes.localHash,
193
+ });
194
+ }
195
+ }
196
+ return { changes, hasChanges: changes.length > 0, state };
197
+ }
198
+ const planCmd = async (options) => {
199
+ const { dir = '.', verbose = false, scope, outPath, lock = true, lockTimeout = '0s', variables, isJson = false, detailedExitCode = false, statePath, backupPath, target, destroyPlan = false, refresh = true, refreshOnly = false, replaceTargets, parallelism, compactWarnings = false, testDirectory } = options;
200
+ const logger = new logger_1.Logger(verbose);
201
+ const stateObj = new state_1.State(dir, statePath, backupPath);
202
+ await stateObj.init();
203
+ await stateObj.acquireLock(lock, lockTimeout);
204
+ let plan;
205
+ try {
206
+ if (refresh && !destroyPlan) {
207
+ if (!isJson)
208
+ console.log(const_1.MSG_ACQUIRING_LOCK);
209
+ await (0, refresh_1.refreshState)({ dir, scope, state: stateObj, logger, silent: isJson, parallelismRaw: parallelism, target });
210
+ }
211
+ plan = await calculatePlan({
212
+ dir, scope, variables, statePath, backupPath, target, destroyPlan, refreshOnly, preLoadedState: stateObj, lock, lockTimeout, replaceTargets, compactWarnings, testDirectory
213
+ });
214
+ if (isJson) {
215
+ const planData = {
216
+ testform_version: const_2.VERSION_CLI,
217
+ scope: scope,
218
+ changes: plan.changes
219
+ };
220
+ console.log(JSON.stringify(planData, null, 2));
221
+ }
222
+ else {
223
+ resources_1.resource.summary(plan.changes, true, { state: plan.state, outPath });
224
+ }
225
+ }
226
+ finally {
227
+ await stateObj.releaseLock();
228
+ }
229
+ if (outPath) {
230
+ const path = require('path');
231
+ const resolvedOutPath = path.resolve(dir, outPath);
232
+ const planData = {
233
+ testform_version: const_2.VERSION_CLI,
234
+ scope: scope,
235
+ changes: plan.changes
236
+ };
237
+ try {
238
+ (0, fs_1.writeFileSync)(resolvedOutPath, JSON.stringify(planData, null, 2), 'utf-8');
239
+ console.log(`\nSaved the plan to: ${outPath}\n\nTo perform exactly these actions, run the following command to apply:\n testform apply "${outPath}"`);
240
+ }
241
+ catch (e) {
242
+ logger.error(`Failed to write plan to ${resolvedOutPath}: ${e.message}`);
243
+ }
244
+ }
245
+ if (detailedExitCode) {
246
+ process.exitCode = plan.hasChanges ? 2 : 0;
247
+ }
248
+ return plan;
249
+ };
250
+ exports.planCmd = planCmd;
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ /**
3
+ * @fileoverview `testform refresh` command.
4
+ *
5
+ * Synchronizes the local state file against the remote GitHub repository
6
+ * by checking each tracked issue's current status and updating or removing
7
+ * stale state entries.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.refreshCmd = exports.refreshState = void 0;
11
+ const chalk_1 = require("chalk");
12
+ const github_1 = require("../adapters/github");
13
+ const config_1 = require("../core/config");
14
+ const state_1 = require("../core/state");
15
+ const logger_1 = require("../logger");
16
+ const notify_1 = require("../notify");
17
+ const const_1 = require("../const");
18
+ const refreshState = async (options) => {
19
+ const { dir, scope, state, logger, silent = false, parallelismRaw, target } = options;
20
+ if (!Object.prototype.hasOwnProperty.call(const_1.SCOPE_RESOURCE_MAP, scope)) {
21
+ throw new Error(`Invalid scope: ${scope}`);
22
+ }
23
+ const resourceType = const_1.SCOPE_RESOURCE_MAP[scope];
24
+ let resources = state.getResources(resourceType);
25
+ if (target) {
26
+ resources = resources.filter(r => r.identity === target || r.identity.startsWith(`${target}::`) || r.identity.endsWith(`/${target}`) || r.identity.endsWith(target));
27
+ }
28
+ if (resources.length === 0) {
29
+ await state.save();
30
+ if (!silent)
31
+ console.log('No resources in state to refresh.');
32
+ return;
33
+ }
34
+ const config = new config_1.Config(dir);
35
+ const ghConfig = config.getGitHub();
36
+ if (!ghConfig) {
37
+ if (!silent) {
38
+ notify_1.notify.push({
39
+ type: 'error',
40
+ title: 'GitHub configuration not found',
41
+ detail: [const_1.ERR_GITHUB_CONFIG_NOT_FOUND],
42
+ close: true,
43
+ });
44
+ }
45
+ return;
46
+ }
47
+ const github = new github_1.GitHubAdapter(ghConfig);
48
+ let refreshed = 0;
49
+ let removed = 0;
50
+ const parallelism = parallelismRaw ? parseInt(String(parallelismRaw), 10) || 10 : 10;
51
+ for (let i = 0; i < resources.length; i += parallelism) {
52
+ const batch = resources.slice(i, i + parallelism);
53
+ await Promise.all(batch.map(async (res) => {
54
+ const remoteId = res.attributes.remoteId ?? '';
55
+ const { formatResourceAddress } = require('../core/utils');
56
+ if (!silent)
57
+ console.log(`${formatResourceAddress(res.type, res.identity)}: Refreshing state... ${(0, chalk_1.dim)(`[id=${remoteId}]`)}`);
58
+ try {
59
+ if (!res.attributes.issueNumber) {
60
+ if (!silent)
61
+ console.log((0, chalk_1.yellow)(` ${res.identity}: No issue number — removing from state`));
62
+ state.removeResource(res.identity);
63
+ removed++;
64
+ return;
65
+ }
66
+ const issue = await github.getIssue(res.attributes.issueNumber);
67
+ if (!issue) {
68
+ if (!silent)
69
+ console.log((0, chalk_1.red)(` ${res.identity}: Issue #${res.attributes.issueNumber} not found — removing from state`));
70
+ state.removeResource(res.identity);
71
+ removed++;
72
+ }
73
+ else {
74
+ let driftDetected = false;
75
+ // Sync title
76
+ if (res.attributes.title !== issue.title) {
77
+ res.attributes.title = issue.title;
78
+ driftDetected = true;
79
+ }
80
+ // Helper to compare string arrays case-insensitively
81
+ const arraysEqual = (a = [], b = []) => {
82
+ if (a.length !== b.length)
83
+ return false;
84
+ const sortedA = [...a].sort();
85
+ const sortedB = [...b].sort();
86
+ return sortedA.every((val, index) => val === sortedB[index]);
87
+ };
88
+ const remoteLabels = issue.labels ?? [];
89
+ const localLabels = Array.isArray(res.attributes.labels) ? res.attributes.labels : (res.attributes.labels ? String(res.attributes.labels).split(',') : []);
90
+ if (!arraysEqual(localLabels, remoteLabels)) {
91
+ res.attributes.labels = remoteLabels;
92
+ driftDetected = true;
93
+ }
94
+ const remoteAssignees = issue.assignees ?? [];
95
+ const localAssignees = Array.isArray(res.attributes.assignees) ? res.attributes.assignees : (res.attributes.assignees ? String(res.attributes.assignees).split(',') : []);
96
+ if (!arraysEqual(localAssignees, remoteAssignees)) {
97
+ res.attributes.assignees = remoteAssignees;
98
+ driftDetected = true;
99
+ }
100
+ const remoteMilestone = issue.milestone ?? '';
101
+ const localMilestone = String(res.attributes.milestone ?? '');
102
+ if (localMilestone !== remoteMilestone) {
103
+ res.attributes.milestone = remoteMilestone;
104
+ driftDetected = true;
105
+ }
106
+ // Fetch custom fields
107
+ if (issue.node_id) {
108
+ const remoteCustomFields = await github.getProjectItemFields(issue.node_id);
109
+ const localCustomFields = res.attributes.custom_fields || {};
110
+ // Check all local custom fields to see if they changed or were cleared on remote
111
+ for (const localKey of Object.keys(localCustomFields)) {
112
+ // Find the remote value (case insensitive match)
113
+ const remoteKeyMatch = Object.keys(remoteCustomFields).find(k => k.toLowerCase() === localKey.toLowerCase());
114
+ const remoteVal = remoteKeyMatch ? remoteCustomFields[remoteKeyMatch] : '';
115
+ const localVal = String(Object.prototype.hasOwnProperty.call(localCustomFields, localKey) ? localCustomFields[localKey] : '');
116
+ // Clean prefix '@' if present for comparison
117
+ const cleanLocalVal = localVal.startsWith('@') ? localVal.substring(1).toLowerCase() : localVal.toLowerCase();
118
+ const cleanRemoteVal = remoteVal.toLowerCase();
119
+ if (cleanLocalVal !== cleanRemoteVal) {
120
+ Object.assign(localCustomFields, { [localKey]: remoteVal });
121
+ driftDetected = true;
122
+ }
123
+ }
124
+ res.attributes.custom_fields = localCustomFields;
125
+ }
126
+ if (driftDetected) {
127
+ // Invalidate localHash to force a plan diff
128
+ res.attributes.localHash = 'drift_detected';
129
+ state.upsertResource(res);
130
+ }
131
+ refreshed++;
132
+ }
133
+ }
134
+ catch (error) {
135
+ if (!silent) {
136
+ notify_1.notify.push({
137
+ type: 'warning',
138
+ title: `Failed to refresh ${res.identity}: ${error.message}`,
139
+ detail: [],
140
+ });
141
+ }
142
+ }
143
+ }));
144
+ }
145
+ await state.save();
146
+ if (!silent) {
147
+ console.log('');
148
+ console.log((0, chalk_1.green)((0, chalk_1.bold)(`Refresh complete! ${refreshed} resource(s) refreshed, ${removed} removed.`)));
149
+ }
150
+ };
151
+ exports.refreshState = refreshState;
152
+ const refreshCmd = async (options) => {
153
+ const { dir = '.', verbose = false, scope, lock = true, lockTimeout = '0s', statePath, backupPath, parallelismRaw, compactWarnings, target } = options;
154
+ const logger = new logger_1.Logger(verbose);
155
+ const stateObj = new state_1.State(dir, statePath, backupPath);
156
+ await stateObj.init();
157
+ await stateObj.acquireLock(lock, lockTimeout);
158
+ try {
159
+ await (0, exports.refreshState)({ dir, scope, state: stateObj, logger, silent: false, parallelismRaw, target });
160
+ }
161
+ finally {
162
+ await stateObj.releaseLock();
163
+ }
164
+ };
165
+ exports.refreshCmd = refreshCmd;