diff --git a/CHANGELOG.md b/CHANGELOG.md index c5dca2db..35cf39ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,32 +18,49 @@ Security - in case of vulnerabilities. Format: ## [Unreleased] + - ## [1.0.0] - 2021-01-16 ### Added + - ### Changed + - ### Deprecated + - ### Removed + - ### Fixed + - ### Security + - ### Internal + - --> - +## [Unreleased] + +### Added + +- "Breaking news" feature for ALKilnInThePlayground - a way new ALKiln versions can talk directly with authors even if ALKilnInThePlayground's version stays the same. ALKilnInThePlayground will be able to get text from ALKiln that ALKilnInThePlayground can turn into Mako and then insert on a screen the author can see. + +### Changed + +- Implement constrained random answers Step for ALKilnInThePlayground. + ## [5.15.4] - 2025-11-29 diff --git a/docassemble/ALKilnTests/data/sources/observation_steps.feature b/docassemble/ALKilnTests/data/sources/observation_steps.feature index 64f49d8e..a1e6208e 100644 --- a/docassemble/ALKilnTests/data/sources/observation_steps.feature +++ b/docassemble/ALKilnTests/data/sources/observation_steps.feature @@ -212,7 +212,7 @@ Scenario: I enter the date and time | time_input | 12:34 PM | | @fast @o16 @signature @screenshot -Scenario: I take a screenshot of the signature +Scenario: I take a screenshot of the typed signature Given I start the interview at "test_signature.yml" When I sign with the name "David" And I take a screenshot diff --git a/docassemble/ALKilnTests/data/sources/story_tables.feature b/docassemble/ALKilnTests/data/sources/story_tables.feature index 13619fb6..b72ce64d 100644 --- a/docassemble/ALKilnTests/data/sources/story_tables.feature +++ b/docassemble/ALKilnTests/data/sources/story_tables.feature @@ -119,7 +119,7 @@ Scenario: 0 target_number for there_are_any and target_number lists, 1 for there And I SHOULD see the phrase "target_people people: 0" @slow @st6 @loops -Scenario: target_number 2 for there_are_any, there_is_another, and target_number lists +Scenario: proxy target_number 2 for there_are_any, there_is_another, and target_number lists Given I start the interview at "test_loops.yml" And I take a screenshot And I get to "end" with this data: @@ -138,7 +138,7 @@ Scenario: target_number 2 for there_are_any, there_is_another, and target_number And I SHOULD see the phrase "target_people people: 2" @slow @st6_no_proxy @loops @no_proxy -Scenario: target_number 2 for there_are_any, there_is_another, and target_number lists +Scenario: no proxy target_number 2 for there_are_any, there_is_another, and target_number lists Given I start the interview at "test_loops.yml" And I take a screenshot And I get to "end" with this data: diff --git a/docs/decisions/2025_06_12-breaking_news_feat.md b/docs/decisions/2025_06_12-breaking_news_feat.md new file mode 100644 index 00000000..b0c3b4c5 --- /dev/null +++ b/docs/decisions/2025_06_12-breaking_news_feat.md @@ -0,0 +1,118 @@ +# Document decisions + +## Context and Problem Statement + +Sometimes we want to let authors who use ALKilnInThePlayground (ALKiP) know about changes or enhancements to ALKiln that can affect their use of ALKiP. + +ALKiP is closely coupled with, yet disconnected from, ALKiln. Authors can update to the latest ALKiln version right inside the ALKiP interview, but the relationship between the versions of the two packages can be a little unclear so mistakes are easy to make. + + +## Considered Options + +- Make a new nodejs executable for ALKiP to use +- Leave the system alone and just stick to making announcements + + +## Decision Outcome + +Make a new nodejs executable for ALKiP to use. + + +## Pros and Cons of the Options + +### New executable + +Pros: + +It will let us do something we usually are unable to do - tell our authors new information without requiring them to update ALKiP first. It will keep authors informed who: + +- Don't have the bandwidth to keep up with the community channels +- Don't have the bandwidth to check the changelog regularly +- Are unsure how to align versions of ALKiP with ALKiln + +Cons: + +- It creates a new strange coupling between ALKiln and ALKiP. That said, this new coupling is a very light coupling. +- It gives ALKiln a way to influence ALKiP. We will have to make sure that we review the "breaking news" code to make sure that any Mako that gets executed will be safe. Granted, that is the case with most code everywhere. + + +### Just announcements in community channels + +Pros: + +- Less work +- Less coupling between ALKiln and ALKiP + +Cons: + +- We should keep making announcements in our community channels regardless, but I feel it's insufficient. In GitHub actions, most authors use an up-to-date version of ALKiln. I'm not as sure about ALKiP sitting on author's servers with no indications of updates. Docassemble didn't have indications of an out of date installed packages the last time I looked. + + + + + diff --git a/docs/version_formatting_rules.md b/docs/version_formatting_rules.md new file mode 100644 index 00000000..aeb23d48 --- /dev/null +++ b/docs/version_formatting_rules.md @@ -0,0 +1,83 @@ +# Format for ALKiln version numbers + +These basically follow a subset of [semver](https://semver.org/) rules when we update our npm package version. We should publish new versions when we need to be able to share our code, either for all our authors to use or for testing. Or maybe just to impress our friends. + +Also, remember that version numbers are not a guarantee. Any change may be a breaking change even if we just meant to add a feature or fix a bug. Version numbers just tell our authors what we were trying to do. The road to bugs is paved with good intentions. + +Below are our own definitions of the parts (symbols) that ALKiln's version numbers are made of. If a definition includes other symbols, those symbols will have definitions of their own. + +Characters you may use: + +- `.` (maximum of 3 of these) +- Alphanumeric (A-z, 0-9) +- `-` + + +## Stable versions + +Summary: `major.minor.patch` + +Example: `12.4.105` + +When you publish a new major, minor, or patch version of ALKlin, it should have the format of a `stableVersion`. + +Definitions: + +| symbol | definition | +| -- | -- | +| `stableVersion` | `major.minor.patch` (see definitions below) | +| `.` | A period | +| `major` | An integer. Increase this by 1 to show that we know that the new code breaks previous ALKiln Steps or other features. Old tests may incorrectly fail or succeed. Old config env var values may behave differently than before. | +| `minor` | An integer. Increase this by 1 to show that we have a new feature or features in a way that we intend to be backwards compatible. | +| `patch` | An integer. Increase this by 1 to show that we have fixed a bug or refactored internal code in a way we intend to be backwards compatible. | + + +## Development versions + +Summary: `parentVersion-type-purpose-increment` + +Example: `12.4.105-feat-story-2` + +> [!CAUTION] +> When you publish a development version, you **must** publish using a descriptive `tag`. The `tag` should be a one-word reminder of the purpose of this version. Example: +> +> ``` +> npm publish --tag "story" +> ``` +> +> > If used in the `npm publish` command, this is the tag that will be added to the package submitted to the registry. +> \- [npm docs](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag) +> +> If you forget this then your code counts as the latest official version of ALKiln (as far as npm is concerned) and authors' code will automatically use that code to run tests. +> +> Also, forgetting is not the worst thing in the world. Everyone forgets sometimes. I certainly have. + +When you are publishing a `devVersion` of ALKiln to try out a feature, fix, or other change, use these rules. + +| symbol | definition | +| -- | -- | +| `devVersion` | `parentVersion-type-purpose-increment`. Use this version scheme to try out new changes before publishing them for all authors. | +| `parentVersion` | Has the same format as `stableVersion` (from above). This should be the branch's original version number. Why not a new version number? Because what other core version number do you want to give it? When we start developing a change, we don't know what version number ALKiln will get to before we actually publish this change. The whole world could change in between then and now. | +| `-` | A dash | +| `type` | The type of change this is testing. E.g. `feat`, `fix`, `test`, `docs`, `fort` (see definitions below). If you really need something different, you can come up with your own. Then possibly discuss it and add it to these docs. | +| `purpose` | A one or two word reminder of what specific topic this change is addressing. Really truly try to keep this short. It is just a reminder after all. If you **must** use multiple words, separate them with a dash. | +| `increment` | An integer. Starts at 1. If this will be your first time publishing for this development, use 1. If this is your second time, use 2. And so on. To be very specific, and more confusing: when you publish these changes to try them out, how many times will you have published changes for this `parentVersion-type-purpose` combination? | + +**Some `type`s** + +It is better to choose something closer to the top of the list if the definition fits your changes. + +| symbol | definition | +| -- | -- | +| `feat` | The string "feat". It stands for "feature". Use this when you're making a new feature | +| `fix` | The string "fix". Use this when you're fixing a bug | +| `test` | The string "test". Most code changes should include new tests, of course. This is for changes that improve *only* testing. This will help your fellow devs know when to expect just test changes as opposed to refactoring and so on. | +| `docs` | The string "docs". It stands for "documentation". Use this when you update or add only documentation. This file is an example of documentation! You probably shouldn't need to use this `type`. Why would you need anyone to test docs? Still, there are stranger things, etc. | +| `fort` | The string "fort". It stands for "fortify". Use this when you are strengthening or improving code in other ways. For example, refactoring. Also for situations where pillow forts can be involved. | + + +## Why did we choose these rules? + +1. Simplicity and clarity. Semver has a lot of details our project doesn't need right now, so we can simplify it. Using semver as a base, though, has advantages. It is clear in many ways and a lot of developers are familiar with it. +1. Our `breaking_news` feature compares version numbers to decide what notifications to show to authors that are using ALKilnInThePlayground. We want to keep those version comparison calculations as simple as possible. At the same time, we want to let internal developers be descriptive enough with version names so that they are useful at-a-glance. This doc describes the balance we have chosen. +1. I can't remember another reason right now, but every list should have a minimum of 3 items, so here's the 3rd. diff --git a/lib/breaking_news.js b/lib/breaking_news.js new file mode 100755 index 00000000..dc394a30 --- /dev/null +++ b/lib/breaking_news.js @@ -0,0 +1,1336 @@ +#!/usr/bin/env node + +const { execSync } = require('node:child_process'); + +/** + * Prints and returns updates and notifications that are relevant to a current + * user of this package. It is styled as a newspaper where each article is a + * notification. + * */ + +/** + * TODO: + * - Show a different banner on the pre-installation page and + * post-installation page. "There's something you should know"? + * */ + +// Run the code when it's called as an executable +if (require.main === module) { + /** + * Accept command line args to return a string representing html for updates + * about ALKiln and ALKilnInThePlayground or about mismatches between + * ALKiln and ALKilnInThePlayground versions or an error. + * + * @param {str} [alkiln_current_version] Command line arg. The author's + * server's current version of ALKiln + * @param {str} [alkiln_desired_version] Command line arg. A version of ALKiln + * the author is trying to update to + * @param {str} [alkip_version] Command line arg. An author's server's current + * version of ALKilnInThePlayground + * + * @returns {str} String of html of updates or error object. I assume it can't + * be used. + * + * Examples of running the file: + * node lib/breaking_news.js --alkiln_desired_version=5.15.0 --alkip_version=1.3.1 + * node lib/breaking_news.js --alkiln_current_version=5.15.0 --alkip_version=1.3.2 + * + * Examples of running the package.json script for linux-based OSs: + * npm run news -- --alkiln_desired_version=5.15.0 --alkip_version=1.3.1 + * npm run news -- --alkiln_desired_version=5.15.1 --alkip_version=1.3.0 + * npm run news -- --alkiln_current_version=5.15.0 --alkip_version=1.3.2 + * + * Windows-based Oss may need different syntax for running the package.json script: + * npm run news -- -- --alkiln_desired_version=5.15.0 --alkip_version=1.3.1 + * npm run news -- -- --alkiln_desired_version=5.15.1 --alkip_version=1.3.0 + * npm run news -- -- --alkiln_current_version=5.15.0 --alkip_version=1.3.2 + * See https://stackoverflow.com/a/65530483/14144258 + * + * Example of process.argv: + * [ + * '/Users/me/.nvm/versions/node/v18.17.0/bin/node' + * '/Users/me/code/alkiln/lib/breaking_news.js' + * '--alkiln_desired_version=6.20.0' + * '--alkip_version=1.0.0' + * ] + * */ + const argv = require(`minimist`)( process.argv.slice(2) ); + let { + alkiln_desired_version, + alkiln_current_version, + alkip_version, + } = argv; + + let news = try_to_break_the_news_gently({ + alkiln_desired_version, + alkiln_current_version, + alkip_version, + }); + return news; +} + +// Export the function if it is being imported +module.exports = { try_to_break_the_news_gently }; + + +function try_to_break_the_news_gently({ + alkiln_desired_version, + alkiln_current_version, + alkip_version, + } = {}) { + /** + * Returns a string representing html for updates about ALKiln and + * ALKilnInThePlayground or about mismatches between ALKiln and + * ALKilnInThePlayground versions or a string representing an error + * object. + * + * Note that the function prints the value to the console before returning the + * value. This is the only way ALKilnInThePlayground can get the value. + * + * @param {str} [alkiln_current_version] The author's server's current version + * of ALKiln + * @param {str} [alkiln_desired_version] A version of ALKiln the author is + * trying to update to + * @param {str} [alkip_version] An author's server's current version of + * ALKilnInThePlayground + * + * @returns {str} String of html of updates or error object. + * + * Examples: + * try_to_break_the_news_gently({ alkiln_desired_version: "5.15.0", alkip_version: "1.3.1" }) + * try_to_break_the_news_gently({ alkiln_desired_version: "5.15.1", alkip_version: "1.3.0" }) + * try_to_break_the_news_gently({ alkiln_current_version: "5.15.0", alkip_version: "1.3.1" }) + * try_to_break_the_news_gently({ alkiln_current_version: "5.15.0", alkip_version: "1.3.2" }) + * try_to_break_the_news_gently() + * */ + let news = null; + try { + news_str = break_the_news({ + alkiln_desired_version, + alkiln_current_version, + alkip_version, + }); + + } catch ( breaking_news_error ) { + + let code = `ALK0279`; + news_str = JSON.stringify({ + ok: false, code: code, types: `note`, + error: `🖊️ ${ code } NOTE breaking news: Unable to break any news.\n${ breaking_news_error.stack }\n` + }); + } + + let alkip_str = news_str + `\n`; + if (require.main === module) { process.stdout.write( alkip_str ); } + return news_str; +} + + +function break_the_news({ + alkiln_desired_version, + alkiln_current_version, + alkip_version, + }) { + /** + * Returns a string representing html for updates about ALKiln and + * ALKilnInThePlayground or about mismatches between ALKiln and + * ALKilnInThePlayground versions. The string may say that there are none + * of those that are currently relevant. + * + * Discuss: Should we integrate Log into here for, e.g., debugging? + * + * @param {str} [alkiln_current_version] The author's server's current version + * of ALKiln + * @param {str} [alkiln_desired_version] A version of ALKiln the author is + * trying to update to + * @param {str} [alkip_version] An author's server's current version of + * ALKilnInThePlayground + * + * @returns HTML string of the notifications to show the author styled as a + * newspaper. May be a notification of no notifications. + * + * Examples: + * break_the_news({ alkiln_desired_version: "5.15.0", alkip_version: "1.3.1" }) + * break_the_news({ alkiln_desired_version: "5.15.1", alkip_version: "1.3.0" }) + * break_the_news({ alkiln_current_version: "5.15.0", alkip_version: "1.3.1" }) + * break_the_news({ alkiln_current_version: "5.15.0", alkip_version: "1.3.2" }) + * break_the_news({}) + * */ + let alkiln_desired_semver = get_semver_parts( alkiln_desired_version ); + let alkiln_current_semver = get_semver_parts( alkiln_current_version ); + let alkip_semver = get_semver_parts( alkip_version ); + + // Hard-coded metadata for "article" (notification) categories + // Discuss: Abstract `notification_types`? + let notification_types = { + feature: { + // This icon won't show up well on a light background + icon: `✨`, short: "Feature", body: `New feature`, types: `feature info news`, + subheads: [ + `A new feature is here!`, + `A new feature is sweeping the nation`, + `"I can't imagine living without this feature!"`, + `Everyone is talking about this new feature`, + `Do your neighbors already have this feature?`, + `"Wow. This feature. Just wow."`, + `"This feature made my day"`, + `When will you get your hands on this feature?`, + `A new feature for you to savor`, + ], + }, + fix: { + icon: `🛠️`, short: "Fix", body: `Bug fix`, types: `fix warning news`, + subheads: [ + `"Gosh was I happy to get this fix!"`, + `9 out of 10 doctors agree—this fix is top notch`, + `"This fix came just in time for my wedding!"`, + `"It's darn time to squash this bug"`, + `Squash one bug a day—that's when we slay`, + // `Don't go astray, squash one bug a day`, + `Squash one bug a week—now that's some cheek`, + `Squash one bug biweekly—that's getting sneaky`, + `Squash one bug a month when we're under a crunch`, + `Squash one bug a year, almost perfect my dear`, + ], + }, + versions: { + icon: `🔀`, short: "Version mismatch", body: `ALKiln and ALKilnInThePlayground have misaligned versions`, types: `versions warning news`, + subheads: [ + `City under threat of version confusion`, + `"Was that your version or mine?"`, + `Versions under pressure!`, + `On a roll with version control`, + `"My folks told me to choose my versions wisely."`, + `Mayor warns: "Watch those versions!"`, + `"Got my versions crossed and this solved it"`, + ], + }, + newsless: { + icon: `🌈`, short: `No news`, body: `No news`, types: `info news`, + subheads: [ `When news doesn't break and no one can hear it, does it make a sound?`, ], + }, + } + + // Hard-coded logic and data for articles and whether they should be included + // Discuss: abstract `articles_data`? + let articles_data = [ + { + // This first one is for testing and will never be seen in real runs— + // Authors would need to update ALKiP > 1.3.x, ALKiln > 5.15.1 to see + // this article at which point `relevant` would be `false`. + + // * npm run news -- --alkiln_desired_version=5.15.0 --alkip_version=1.3.1 + // * node lib/breaking_news.js --alkiln_desired_version=5.15.0 --alkip_version=1.3.1 + headline: `Generate randomized tests`, + body: [`Install ALKiln 5.15.0 or above on your server to create the new constrained randomized test generator table. An author can make a generator file. In that file, an author can write a table that contains many possible interview answers. ALKiln can generates many tests by picking random answers from that table. ALKiln then saves and runs those tests. Follow the instructions in the linked documentation to create a generator file.`], + relevant: ( + none_are_null( alkiln_desired_semver, alkip_semver ) + && ( + ( + semver_a_more_recent_than_b( alkiln_desired_semver, [5, 15, 0] ) // ex: 5.15.1 + || semver_a_same_as_b( alkiln_desired_semver, [5, 15, 0] ) // ex: 5.15.0 + ) && ( + semver_a_less_recent_than_b( alkip_semver, [1, 3, 2] ) // ex: 1.3.1 + ) + ) + ), + log_code: `ALK0280`, + date_written: `July 22, 2025`, + version_at_publication: `5.15.1`, + publication_volume: 1, + notification_types: [ notification_types.versions ], + }, + { + // This one is impossible too. Breaking news won't appear till ALKiln >= + // 5.15.1 and ALKip > 1.3.1 + headline: `Get ALKiln notifications`, + body: [`Install ALKilnInThePlayground 1.3.1 or above on your server to get updates about ALKiln and ALKilnInThePlayground. Then you can get notifications here about any mismatches between your ALKiln version and your ALKilnInThePlayground version, new features, bug fixes, and more.`], + relevant: ( none_are_null( alkiln_current_semver, alkip_semver ) + && semver_a_less_recent_than_b( alkiln_current_semver, [5, 15, 1] ) // ex: 5.15.0 + && semver_a_more_recent_than_b( alkip_semver, [1, 3, 1] ) // ex: 1.3.2 + ), + log_code: `ALK0281`, + date_written: `July 22, 2025`, + version_at_publication: `5.15.1`, + publication_volume: 1, + notification_types: [ notification_types.feature ], + }, + { + is_no_news_notification: true, + headline: `Nothing to see here, folks`, + body: [`No broken news. Probably.`], + relevant: true, + log_code: `ALK0278`, + date_written: `Every day in hope`, + version_at_publication: `All`, + publication_volume: 1, + notification_types: [ notification_types.newsless ], + }, + ]; + + let { articles_section, publication_volume } = get_relevant_articles_parts({ articles_data }); + + let news = [ + `
`, [ + `
`, [ + // What is a masthead? The whole top? Each row that has metadata? + ...get_top_masthead_parts(), + `

The Furnace

`, + ...get_flag_masthead_parts({ publication_volume }), + // BREAKING NEWS!!! with kerning adjustments + ``, + ...articles_section, + ``, + ], `
`, + ], `
`, + ]; + + let all_parts = wrap_inner_parts( ...get_css_and_svg(), news ); + let news_html = get_html( all_parts ); + return news_html; +} + +// ============= +// Helpers +// ============= + +function get_relevant_articles_parts({ articles_data }) { + /** + * Returns a nested list of HTML strings or lists of HTML strings or both for + * the articles for each relevant notification, as well as the most recent + * "publication volume" number. + * + * @param {obj} obj - Named arguments + * @param {arr} obj.articles_data - List of data for every possible "article" + * where an article is the data for notifications that might be useful to + * an author based on their current versions of ALKiln and + * ALKilnInThePlayground, or for other reasons. + * @param {bool} [obj.articles_data[n].is_no_news_notification] - True if this + * is the notification that there is no relevant information for the + * author. + * @param {str} obj.articles_data[n].headline - Short description of the + * content of the notification. + * @param {arr} obj.articles_data[n].body - Paragraphs in the article + * @param {bool} obj.articles_data[n].relevant - Whether to show the article + * @param {str} obj.articles_data[n].log_code - ALKiln log code (see Log.js) + * @param {str} obj.articles_data[n].date_written - Date the developer added + * the article to the file + * @param {str} obj.articles_data[n].version_at_publication - Semver ALKiln + * version of the file when the developer added the article to the file + * @param {int} obj.articles_data[n].publication_volume - Incrementing number + * identifying a group of articles that were published together. The + * volumes are incremented in chronological order. + * @param {arr} obj.articles_data[n].notification_types - List of 1 or more + * objs containing metadata that could be useful for an article. For + * example, why this is being shown (e.g. because this is a new feature). + * @param {str} obj.articles_data[n].notification_types[n].icon - UTF-8 emoji + * for this notification type + * @param {str} obj.articles_data[n].notification_types[n].short - Summary of + * the notification type. E.g. "Feature". + * @param {str} obj.articles_data[n].notification_types[n].body - Long + * description of the notification type + * @param {str} obj.articles_data[n].notification_types[n].types - + * Space-separated categories for the notification type. Aligns with + * Log.js types. Discuss other possible property names. + * @param {} obj.articles_data[n].notification_types[n].subheads - List of 1 + * or more "news article" subheadings. These are flavor text. + * + * @returns {obj} obj - Named return values + * @returns {arr} obj.articles_section - Nested lists of HTML strings or lists + * of HTML strings or both + * @returns {int} obj.publication_volume - The number of the most recent group + * of published articles + * */ + + let articles_section = []; + let subheads_used = {}; + let used_exclamation = false; + let used_question = false; + let used_quotes = false; + let final_publication_volume = 0; + for ( let article of articles_data ) { + + if ( !article.relevant ) { continue; } + let there_is_some_news = articles_section.length > 0; + if ( there_is_some_news && article.is_no_news_notification ) { continue; } + + let shuffled_subheads = get_shuffled_subheads({ article }); + + let final_subhead = `We do what we must because we can`; + for ( let subhead of shuffled_subheads ) { + + if ( subhead.includes(`!`) && used_exclamation ) { continue; } + if ( subhead.includes(`?`) && used_question ) { continue; } + if ( subhead.includes(`"`) && used_quotes ) { continue; } + if ( subheads_used[ subhead ]) { continue; } + + if ( subhead.includes(`!`)) { used_exclamation = true; } + if ( subhead.includes(`?`)) { used_question = true; } + if ( subhead.includes(`"`)) { used_quotes = true; } + + final_subhead = subhead; + subheads_used[ subhead ] = true; + } + + if ( article.publication_volume > final_publication_volume ) { + final_publication_volume = article.publication_volume + } + + articles_section.push( get_one_articles_parts({ article: article, subhead: final_subhead })); + } // end for each article + + return { + articles_section: wrap_articles({ articles: articles_section }), // Discuss: the name of the returned object's property and the name of the articles variable should not be the same - `articles_section` + publication_volume: final_publication_volume, + }; +}; + + +function get_shuffled_subheads({ article }) { + /** + * Return a list of all the subheadings for all the notification_types for one article, + * shuffled into a pseudo random order. + * + * @param {obj} obj - Named arguments + * @param {obj} obj.article - Data for one alkiln notification + * @param {arr} obj.article.notification_types - One or more notification type + * @param {[str]} obj.article.notification_types[n].subheads - One or more + * possible article subheadings that come with this notification type + * + * @returns {[str]} - Shuffled list of possible subheadings for this article + * */ + + let notification_types_subheads = []; + for ( let one_notification_type of article.notification_types ) { + notification_types_subheads = notification_types_subheads.concat( one_notification_type.subheads ); + } + let shuffled_subheads = get_shuffled_copy(notification_types_subheads); + + return shuffled_subheads; +}; + + +function get_shuffled_copy( to_shuffle ) { + /** + * Return a shuffled copy of `to_shuffle`. `to_shuffle` stays unchanged. + * https://stackoverflow.com/a/2450976/14144258 + * + * @param {arr} to_shuffle - A list of 0 or more items to copy shallowly and + * shuffle + * + * @returns {arr} - A shuffled copy of the given array + * */ + let shuffled = [...to_shuffle]; + let current_index = shuffled.length; + + // While there remain elements to shuffle... + while ( current_index != 0 ) { + + // Pick a remaining element... + let random_index = Math.floor(Math.random() * current_index); + current_index--; + + // And swap it with the current element. + [ shuffled[current_index], shuffled[random_index] ] = [ + shuffled[random_index], shuffled[current_index] ]; + } + return shuffled; +}; + + +function get_one_articles_parts({ article, subhead }) { + /** + * Return the html parts of the appropriate article as a nested list + * + * @param {obj} obj - Named arguments + * @param {obj} obj.article - Data for the "article"/notification + * @param {str} obj.article.headline - Summary of the notification + * @param {[str]} obj.article.body - 1 or more paragraphs describing the + * notification + * @param {str} obj.article.log_code - Unique identifier for this message + * @param {str} obj.subhead - Flavor text for the "article" + * + * @returns {[str | [...]]} - Strs and nested lists - the article html parts + * */ + let article_parts = [ + `
`, + [ + `

${ article.headline }

`, + `
${ subhead }
`, + ...get_paragraphs({ paragraphs: article.body }), + `

- Log code ${ article.log_code }

`, + // `

Written: ${ article.date_written }

`, + ], + `
`, + ]; + + return article_parts; +}; // Ends get_one_articles_parts() + + +function get_paragraphs({ paragraphs }) { + /** + * Returns the same list of strs, but wrapped in appropriate html paragraphs + * + * @param {obj} obj - Named arguments + * @param {[str]} paragraphs - Text of the paragraphs of an article + * + * @returns [str] - The same strings except wrapped in the appropriate html + * */ + let html_paragraphs = []; + for ( let one_paragraph of paragraphs ) { + html_paragraphs.push(`

${ one_paragraph }

`); + } + return html_paragraphs; +}; + + +function wrap_articles({ articles }) { + /** + * Returns the expanded articles list surrounded by the appropriate html tags + * to contain the articles + * + * @param {obj} obj - Named arguments + * @param {arr} obj.articles - List. The contents are irrelevant. + * + * @returns {[str | [...]]} List of strs and nested lists of the same + * containing the given `articles` + * */ + return [ + `
`, + ...articles, + `
`, + ]; +} + + +function get_top_masthead_parts() { + /** + * Return a list of the html parts of top-most broadsheet metadata + * + * @returns {[str | [...]]} List of html strs and nested lists of the same + * */ + + // Discuss: Only have one slogan or pick from multiple slogan options? + let slogans = [`Turning up the heat since 2020`]; + // Other possible options: + // Baked well/Well baked/Baking well since... + // Fired up since/Fired up in... + // Strengthening good work since... + + return [ + `
`, + [ + `${ process.env.DISTRIBUTOR || "ALKilnInThePlayground" }`, + `${ slogans[ Math.floor( Math.random() * slogans.length )] }`, + ], + `
`, + ]; +}; + + +function get_flag_masthead_parts({ publication_volume }) { + /** Return the html parts of the metadata (below the flag) as a nested list + * + * @param {obj} obj - Named arguments + * @param {str | int} obj.publication_volume - Volume of the latest printing + * + * @returns [str | [...]] - List of the nested html parts of the flag masthead + * */ + let { version, date } = get_data_for_the_last_change_in_this_file(); + return [ + `
`, + [ + `0¢/Vol. ${ publication_volume }`, + `Via ALKiln v${ version }`, + `${ date }`, + ], + `
`, + ]; +}; + + +function wrap_inner_parts() { + /** + * Return a list of the outermost html of the broadsheet bracketing the given + * arguments. Includes comments. + * + * @param {[*]} arguments - Probably nested lists of strs and lists + * + * @returns {[str, arguments, str]} - The expanded arguments sandwiched + * between the starting and ending html of the whole broadsheet + * */ + return [ + ` + + + +
`, + ...arguments, + `
`, + ]; +}; + + +function get_html( html_list ) { + /** + * Returns a flat list of strings + * */ + let flattened = indent_and_flatten_html_list( html_list ); + return flattened.join(`\n`); +} + + +function indent_and_flatten_html_list( list_or_str, indent=0 ) { + /** Return a list of lists or strings or both as a flat list of strings. + * If given a string, return a list with that string. In addition, + * each string will get indented by a number of spaces. The number of + * spaces increases as the lists become more deeply nested. + * + * At the moment, this function only adds indentation to the start of each + * string. It doesn't handle indenting every new line. + * + * Watch out—it's recursive. + * + * Example: + * foo = [ `
`, [ `Some stuff`, `
`, [ `Other text` ],
], `
`, ] + * console.log(indent_and_flatten_html_list(nested_html)) + * [ `
`, ` Some stuff`, `
`, ` Other text`, `
`, `
` ] + * + * @param {[arr | str]} - List or string + * @param {int >= 0} - Level of indentation (determines the number of spaces + * to put at the start of a string) + * + * @returns {[str]} - List of 0 or more strings + * */ + + // Terminal case + if ( typeof list_or_str === `string` ) { + indent_str = ` `.repeat( indent ); + return [ indent_str + list_or_str ]; + } + + let flattened = []; + for ( let nested_str_or_list of list_or_str ) { + let result = indent_and_flatten_html_list( + nested_str_or_list, + indent + 1 + ); + // // .concat() feels evil because it accepts a string as well + // flattened = flattened.concat( result ); + flattened.push( ...result ); + } + + return flattened; +} + + +function get_semver_parts( version_str ) { + /** Given a semver (https://semver.org) formatted version string, return a + * list of ints, and possibly strings, that make up the parts of that + * version string. Otherwise returns null. + * + * Note: This makes assumptions about our semver format: + * - There will be three or less `.` characters. + * - After the final period will come an integer which may be followed by + * - Nothing or + * - A `-` followed by other valid semver characters + * + * @example + * // returns [3, 1, 15, `feat-news-1`] + * get_semver_parts(`3.1.15-feat-news-1`) + * @example + * // returns null + * get_semver_parts(`3.a.15-feat-news-1`) + * + * @param {str} version_str - See ./docs/version_formatting_rules.md + * + * @returns {[int, int, ...int|str]} - Version as ints and maybe also strs. + * */ + if ( !version_str || typeof( version_str ) !== `string` ) { return null; } + + let v_parts_strs = version_str.split(`.`); + let initial_parts = strs_to_ints_or_strs( v_parts_strs ); + + if ( initial_parts.length < 3 ) { return null; } + if ( + typeof( initial_parts[0] ) !== `number` + || typeof( initial_parts[1] ) !== `number` ) { + return null; + } + + let version_parts = [ initial_parts[0], initial_parts[1] ]; + + // Handle "experimental" versions. E.g. "3.1.15-feat-news" + if ( typeof( initial_parts[2] ) === `string`) { + let patch_parts = split_around_first_dash( initial_parts[2] ); + let patch_num = str_to_int_or_str( patch_parts[0] ); + version_parts.push( patch_num, patch_parts[1] ); + + } else if ( typeof( initial_parts[2] ) === `number`) { + version_parts.push( initial_parts[2] ); + + } else { + return null; + } + + return version_parts; +} + + +function strs_to_ints_or_strs( strs ) { + /** Given a list of strings, turn each int string into an actual int and + * leaves the rest as-is. + * + * @example + * // returns [ 2, 10, 1 ] + * strs_to_ints_or_strs([ `2`, `10`, `1` ]) + * + * @example + * // returns [ 2, 10, `1-fix-typo` ] + * strs_to_ints_or_strs([ `2`, `10`, `1-fix-typo` ]) + * + * @params {[str]} strs - Strings + * + * @returns {[int|str]} - List of ints or strings or both + * + * */ + let ints_and_or_strs = []; + for ( let one_str of strs ) { + ints_and_or_strs.push( str_to_int_or_str( one_str )); + } + + return ints_and_or_strs; +} + + +function str_to_int_or_str( original_str ) { + /** Converts a string to an int if it contains only digits. Otherwise, returns + * the original string. + * + * @param {str} original_str - The string to convert. + * + * @returns {int|str} - An int if reasonable, otherwise the original string. + */ + try { + + if ( includes_non_digit( original_str )) { return original_str; } + return parseInt( original_str ); + + } catch ( str_to_int_error ) { + return original_str; + } +} + + +function includes_non_digit( original_str ) { + /** Returns whether the given string contains any non-digit characters. + * + * @param {str} original_str - The string to check. + * + * @returns {bool} - true if the string has non-digit chars, otherwise false. + */ + return original_str.match(/\D/); +} + + +function split_around_first_dash( original_str ) { + /** Splits a string into two parts around the 1st dash (`-`). Excludes the + * dash itself. + * + * @example + * // returns [ 3, `feat-2` ] + * split_around_first_dash( `3-feat-2` ) + * + * @param {str} original_str - The string to be split. + * + * @returns {[str, str]} - The str before the 1st dash and the str after. + */ + let parts = original_str.match(/^([^-])-(.+)$/); + return [ parts[1], parts[2] ]; +} + + +function none_are_null( ...items ) { + /** Returns true if all items are not `null`. Otherwise, returns false. + * + * @param {...*} items - The items to be checked. + * + * @returns {bool} - true if none of the items are null, otherwise false. + */ + return items.every(( item ) => { return item !== null; }); +} + + +function semver_a_more_recent_than_b( semver_a, semver_b ) { + /** Compares two semvers to see if the 1st semver is more recent than the 2nd. + * + * WARNING: This function doesn't differentiate well between different dev + * versions, as described in ./docs/version_formatting_rules.md + * + * @param {[int|str]} semver_a - The 1st semver (more recent semver?) + * @param {[int|str]} semver_b - The 2nd semver. + * + * @returns {bool} - true if semver_a is more recent than semver_b, otherwise + * false. + */ + for ( let index = 0; index < semver_a.length; index++ ) { + if ( semver_b[ index ] === undefined ) { + // a is longer than b + return true; + } + if ( semver_a[ index ] > semver_b[ index ] ) { + return true; + } + if ( semver_a[ index ] < semver_b[ index ] ) { + return false; + } + } + + return false; +} + +function semver_a_less_recent_than_b( semver_a, semver_b ) { + /** Compares two semvers to see if the 1st semver is less recent than the 2nd. + * + * @param {[int|str]} semver_a - The 1st semver (less recent semver?) + * @param {[int|str]} semver_b - The 2nd semver. + * + * @returns {bool} - true if semver_a is less recent than semver_b, otherwise + * false. + */ + let is_less = true; + if ( semver_a_more_recent_than_b( semver_a, semver_b )) { + return false; + } + if ( semver_a_same_as_b( semver_a, semver_b )) { + return false; + } + return true; +} + + +function semver_a_same_as_b( semver_a, semver_b ) { + /** Compares two semver arrays to see if they are the same. + * + * @param {[int|str]} semver_a - The 1st semver as an array of ints &/or strs. + * @param {[int|str]} semver_b - The 2nd semver as an array of ints &/or strs. + * + * @returns {bool} - true if semver_a is the same as semver_b, otherwise + * false. + * */ + let str_a = semver_a.join(``); + let str_b = semver_b.join(``); + return str_a === str_b; +} + + +function get_data_for_the_last_change_in_this_file() { + /** + * If possible, return the version of ALKiln when it last changed this file + * and the string of the date when it was changed. If that's not possible, + * return the current version and the current date. + * + * @returns {{version: str, date: str}} - A version number & a formatted date + * */ + try { + // If we switch to module (import) instead of common (require), use + // https://nodejs.org/api/esm.html#importmetafilename for file path + let most_recent_file_sha = execSync( + `git rev-list -1 HEAD -- "${ __filename }"` + ).toString().trim(); + + let package_json_str = execSync( + `git show ${ most_recent_file_sha }:package.json` + ).toString().trim(); + let package_json = JSON.parse( package_json_str ); + + let date = get_commit_date({ sha: most_recent_file_sha }); + + return { + version: package_json.version, + date, + }; + + } catch ( version_error ) { + // We need the stdout to be clean, so we can't log errors unless debugging + let date = new Date(); + let month_name = new Intl.DateTimeFormat("en-US", { month: "long" }) + .format( date ); + return { + version: process.env.npm_package_version, + date: `${ month_name } ${ date.getDate() }, ${ date.getFullYear() }`, + }; + } +}; + + +function get_commit_date({ sha }) { + /** Return the date of the commit, nicely formatted (whatever that means) + * + * Git params: + * Date: + * %-d 8 Day of the month as a decimal number. (Platform specific) + * %B September Month as locale’s full name. + * %Y 2013 Year with century as a decimal number. + * --date="format-local:%B %-d, %Y" + * + * Exclude diffs + * --no-patch + * + * Isolate date: + * --pretty="%ad" + * + * See: + * https://git-scm.com/docs/git-log#:~:text=has%20no%20effect.-,%2D%2Ddate%3Dformat%3A...,-feeds%20the%20format + * https://strftime.org/ + * https://git-scm.com/docs/git-log#Documentation/git-log.txt---no-patch + * https://git-scm.com/docs/git-log#_commit_formatting + * https://git-scm.com/docs/git-log#Documentation/git-log.txt-ad + * + * Example: + * git show 6346c169ee95270a8d6bd4c45eb975751bfe4383 --no-patch --pretty="%ad" --date="format-local:%B %-d, %Y" + * + * @param {obj} obj - Named arguments + * @param {str} obj.sha - Commit hash (sha) of a commit in this repo + * + * @returns {str} - Formatted date: " , <4-digit year>" + * */ + let full_month = `%B`, day_of_month_min_digits = `%-d`, year_4_digit = `%Y`; + let date_format = `format-local:${ full_month } ${ day_of_month_min_digits }, ${ year_4_digit }`; + let just_date = ` --no-patch --pretty="%ad" --date="${ date_format }"`; + let date = execSync( `git show ${ sha } ${ just_date }` ).toString().trim(); + return date; +}; + + +function get_css_and_svg() { + /** + * Return a list with 1 str - the css and svg of the page. These could be + * variables, but this avoids putting the variable definitions at the top + * of the page where they would cause a fair amount of scrolling. + * + * Discuss: keep these, and possibly other hard-coded values in separate files + * + * @returns {[str]} - List of 1 string for the css and svg html + * */ + return [ ` + + + + + + + + + + + + + + + + + + + + + ` ]; +}; diff --git a/lib/scope.js b/lib/scope.js index c2d73d77..8a586c67 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -2768,7 +2768,7 @@ module.exports = { pic_name = `pic_on-${ short_id }-${ timestamp }`; } - await scope.take_a_screenshot( scope, { path: `./${ scope.paths.scenario }/${ pic_name }.jpg` }); + await scope.take_a_screenshot( scope, { path: `${ scope.paths.scenario }/${ pic_name }.jpg` }); await scope.afterStep( scope ); }, // Ends scope.steps.screenshot() diff --git a/lib/setup/artifacts.js b/lib/setup/artifacts.js index 04b49463..e3a61660 100755 --- a/lib/setup/artifacts.js +++ b/lib/setup/artifacts.js @@ -2,7 +2,7 @@ const session_vars = require(`../utils/session_vars`); const files = require(`../utils/files`); -const Log = require(`../setup/artifacts.js`); +const Log = require(`../utils/Log.js`); /** Reset and refresh artifacts path name */ @@ -12,9 +12,15 @@ session_vars.delete_artifacts_path_name(); // If using `npm run cucumber` (as opposed to the bin commands), // use `--` before `--sources=./foo` const argv = require(`minimist`)( process.argv.slice(2) ); -const artifacts_path = files.make_artifacts_folder( argv.path ); + +const artifacts_path = files.make_artifacts_folder(); session_vars.save_artifacts_path_name( artifacts_path ); -// Leave the config Project name value as it is. Local developers find it -// useful to re-use that value. +const log = new Log({ path: artifacts_path, context: `artifacts` }); +log.info({ code: `ALK0284`, context: `artifacts`,}, + `Created the artifacts folder at "${ artifacts_path }"` +); + +// This is just for artifacts, not any other config values. Leave the config +// Project name value as it is. Local developers find it useful to re-use it. diff --git a/lib/setup/validate.js b/lib/setup/validate.js index 1255c372..3693b449 100755 --- a/lib/setup/validate.js +++ b/lib/setup/validate.js @@ -32,7 +32,6 @@ function validate_Gherkin_files() { * generator files. If there are any, it logs those errors and then finally * throws an error. Uses the values in command line arguments. * */ - let sources_paths = set_sources_paths({ log }); let all_errors = []; @@ -44,10 +43,16 @@ function validate_Gherkin_files() { // Discuss: If an author has only some files with syntax errors, should we try // to still run the tests for the files that had valid syntax? if ( all_errors.length > 0 ) { + let parse_error_msg = `ALKiln ran into Gherkin syntax error(s) when parsing ` + + `these test feature files. See warnings and errors above.` log.throw({ code: `ALK0230`, context: `validating files`, - error: new Error( `ALKiln ran into Gherkin syntax error(s) when parsing your test feature files. See warnings and errors above.` ) + error: new Error( parse_error_msg ) }); }; + + log.info({ code: `ALK0283`, context: `validating files`,}, + `Validated all feature files` + ); } @@ -64,15 +69,23 @@ function parse_all_feature_files_in({ sources_path }) { * */ let all_AST_errors = []; - let feature_file_paths = fast_glob.sync( - `${ sources_path }/**/*.feature`, - // These files were generated last time and will get deleted before the run - { ignore: [`${ sources_path }/${ globals.generated_files_folder_name }/*`] } - ); + let feature_file_paths = null; + try { + feature_file_paths = fast_glob.sync( + `${ sources_path }/**/*.feature`, + // These files were generated last time and will get deleted before the run + { ignore: [`${ sources_path }/${ globals.generated_files_folder_name }/*`] } + ); + } catch ( feature_paths_error ) { + log.error({ code: `ALK0282`, context: `validating files`, }, + `Error while trying to find feature files at "${ sources_path }"`, + feature_paths_error + ); + return { errors: [ feature_paths_error ]}; + } for ( let feature_path of feature_file_paths ) { - - let file_text = fs.readFileSync( feature_path, { encoding: `utf8` }); + let file_text = fs.readFileSync( feature_path, { encoding: `utf8` } ); let { AST, errors: these_AST_errors } = get_Gherkin_AST({ file_text }); if ( these_AST_errors.length > 0 ) { diff --git a/lib/steps.js b/lib/steps.js index f3b5bc02..deae2289 100644 --- a/lib/steps.js +++ b/lib/steps.js @@ -25,6 +25,9 @@ const session_vars = require('./utils/session_vars'); const files = require('./utils/files' ); const reports = require(`./utils/reports`); const da_api = require('./docassemble/docassemble_api_interface'); +const globals = require(`./globals`); + +const GENERATED_FILES_FOLDER_NAME = globals.generated_files_folder_name; /* Of Note: - We're using `*=` for selectors because sometimes da text has funny characters in it that are hard to anticipate @@ -118,6 +121,9 @@ Before(async (scenario) => { // Start the running "progress bar" for the Scenario log.stdout({}, `\nScenario: ${ scenario.pickle.name }: `); + // Save Scenario data in Scenario folder + // '@alkiln @generated' + // console.log( JSON.stringify( scenario ) ); // Create browser, which can't be created in BeforeAll() where .driver doesn't exist for some reason if (!scope.browser) { @@ -155,6 +161,16 @@ Before(async (scenario) => { fs.mkdirSync( scope.paths.scenario ); // Store path name for this Scenario's individualized report scope.paths.scenario_report = `${ scope.paths.scenario }/report.txt`; + // If it's a generated Scenario, save the `.feature` file in here + // GENERATED_FILES_FOLDER_NAME + if ( scenario.gherkinDocument.uri.includes("_alkiln_generated") ) { + try { + let file_text = fs.readFileSync( scenario.gherkinDocument.uri, {encoding: `utf8`}); + fs.appendFileSync( `${ scope.paths.scenario }/${ filename }`, file_text ); + } catch ( saving_feat_file_error ) { + // Ignore. This is not crucial. + } + } // Downloads scope.downloadComplete = false; diff --git a/lib/utils/Log.js b/lib/utils/Log.js index bbeb40c4..b32aa41f 100644 --- a/lib/utils/Log.js +++ b/lib/utils/Log.js @@ -228,6 +228,7 @@ class Log { } if ( !fs.existsSync( dir )) { + process.stdout.write(`Making artifacts folder in Log at ${ dir }`) files.make_artifacts_folder( dir ); } diff --git a/lib/utils/files.js b/lib/utils/files.js index 3cd1b6e1..c13ddfea 100644 --- a/lib/utils/files.js +++ b/lib/utils/files.js @@ -1,5 +1,6 @@ const fs = require('fs'); const safe_filename = require('sanitize-filename'); +const path = require(`path`); let files = {}; @@ -21,27 +22,30 @@ files.readable_date = function() { return fancy_str; }; // Ends files.readable_date() -files.make_artifacts_folder = function (path) { +files.make_artifacts_folder = function ( file_path ) { /** Makes a folder in the root of the project if it doesn't * already exist, either with the given name or with a default name. * * Works for local development as well, without action.yml or workflow files. * */ - if (path) { - let name_parts = path.split(`/`); + if (file_path) { + let name_parts = file_path.split(`/`); let safe_parts = []; for ( let part of name_parts ) { safe_parts.push(safe_filename( part )); } - path = safe_parts.join(`/`); + file_path = path.join( ...safe_parts ); } else { - path = `alkiln-${ files.readable_date() }` + file_path = `alkiln-${ files.readable_date() }` } + const root = process.cwd(); + root_path = path.join( root, file_path ); + // `recursive` avoids errors if folder already exists // https://nodejs.org/docs/latest-v18.x/api/fs.html#fspromisesmkdirpath-options - fs.mkdirSync( path, { recursive: true }); - return path; + fs.mkdirSync( root_path, { recursive: true }); + return root_path; }; // Ends files.make_artifacts_folder() module.exports = files; diff --git a/lib/utils/session_vars.js b/lib/utils/session_vars.js index d4986d30..964f3551 100644 --- a/lib/utils/session_vars.js +++ b/lib/utils/session_vars.js @@ -52,6 +52,10 @@ session_vars.get_origin = function () { }; session_vars.get_user_project_name = function () { return process.env._PROJECT_NAME || null; }; session_vars.get_user_id = function () { return process.env._USER_ID || null; }; +session_vars.get_runtime_config_path = function () { + const root = process.cwd(); // absolute path just to be sure + return path.join( root, `runtime_config.json` ); +} // More complex logic session_vars.get_server_reload_timeout = function () { @@ -133,7 +137,6 @@ session_vars.get_languages = function () { }; // Ends session_vars.get_languages() -const runtime_config_path = `runtime_config.json`; const project_name_key = `da_project_name`; session_vars.save_project_name = function ( project_name ) { /* Saves the name of the project to a json obj so deletion can use it later. @@ -142,6 +145,7 @@ session_vars.save_project_name = function ( project_name ) { * Should we try to prevent someone overwriting a previous project name? * * @param project_name { string } - purely alphanumeric string */ + const runtime_config_path = session_vars.get_runtime_config_path(); // Warn local developers if file already exists if ( fs.existsSync( runtime_config_path ) ) { @@ -172,6 +176,8 @@ session_vars.get_project_name = function () { if ( session_vars.get_origin() === 'playground' ) { return session_vars.get_user_project_name(); } + + const runtime_config_path = session_vars.get_runtime_config_path(); let json = JSON.parse( fs.readFileSync( runtime_config_path )); let project_name = json[ project_name_key ] || null; if ( session_vars.get_debug() ) { console.debug( `🐛 ALK0037 DEBUG: Project name from file is "${ project_name }".` ); } @@ -180,6 +186,7 @@ session_vars.get_project_name = function () { session_vars.delete_project_name = function () { /* Empty the key storing the name of the docassemble Project */ + const runtime_config_path = session_vars.get_runtime_config_path(); try { // Get the json, changing it, and re-write the file from scratch let json = JSON.parse( fs.readFileSync( runtime_config_path )); @@ -252,6 +259,7 @@ session_vars.save_runtime_config_var = function ({ key, value }) { * * @returns {binary?} file Runtime config file */ + const runtime_config_path = session_vars.get_runtime_config_path(); // Ensure runtime config file exists if ( !fs.existsSync( runtime_config_path ) ) { @@ -277,6 +285,7 @@ session_vars.get_runtime_config_var = function ( key ) { * * @returns {any} - Value of given key in runtime config file or null */ + const runtime_config_path = session_vars.get_runtime_config_path(); try { let json = JSON.parse( fs.readFileSync( runtime_config_path )); let value = json[ key ]; @@ -300,6 +309,7 @@ session_vars.delete_runtime_config_var = function ({ key }) { * * @returns {null} * */ + const runtime_config_path = session_vars.get_runtime_config_path(); try { if ( !fs.existsSync( runtime_config_path )) { return null; } diff --git a/lib/utils/set_sources_paths.js b/lib/utils/set_sources_paths.js index 7e2b5717..e37a9b10 100644 --- a/lib/utils/set_sources_paths.js +++ b/lib/utils/set_sources_paths.js @@ -1,4 +1,5 @@ const fast_glob = require(`fast-glob`); +const path = require(`path`); const session_vars = require(`./session_vars`); @@ -18,7 +19,7 @@ module.exports = function set_sources_paths({ log }) { let maybe_paths = get_potential_paths(); let sources_paths = get_sources_paths({ maybe_paths }); - if ( sources_paths === 0 ) { + if ( sources_paths.length === 0 ) { // If we don't throw, it'll be more confusing when no tests run log.throw({ code: `ALK0050`, context: `sources`, error: new ReferenceError( @@ -45,6 +46,7 @@ function get_potential_paths() { // `project` is the name in the server docassemble "Project" page let project = session_vars.get_user_project_name(); let id = session_vars.get_user_id(); + let root = process.cwd(); // process.argv is a list of strings of commands and args. // Internal note: If using `npm run cucumber`, use `--` before @@ -53,8 +55,8 @@ function get_potential_paths() { default: { sources: [ `./docassemble/*/data/sources`, // In GitHub folder - `/usr/share/docassemble/files/playgroundsources/${id}/${project}/*.feature`, // From playground without S3 - `/tmp/playgroundsources/${id}/${project}/*.feature` // From playground with S3 + `/usr/share/docassemble/files/playgroundsources/${id}/${project}`, // From playground without S3 + path.join( root, `/playgroundsources/${id}/${project}`), // From playground with S3 ], } }); @@ -81,16 +83,16 @@ function get_sources_paths({ maybe_paths }) { // Check every path, tracking which exist and which are missing let existing_paths = []; let missing_paths = []; - for ( let path of maybe_paths ) { + for ( let maybe_path of maybe_paths ) { let dirs = fast_glob.sync( - [ path ], + [ maybe_path ], { onlyFiles: false, suppressErrors: true } ); if ( dirs.length > 0 ) { existing_paths.push( ...dirs ); } else { - missing_paths.push( path ); + missing_paths.push( maybe_path ); } } diff --git a/package.json b/package.json index e376e90d..ae7c18ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@suffolklitlab/alkiln", - "version": "5.15.4", + "version": "5.15.0-feat-news-17", "description": "Integrated automated end-to-end testing with docassemble, puppeteer, and cucumber.", "main": "lib/index.js", "scripts": { diff --git a/tests/unit_tests/breaking_news/breaking_news.test.js b/tests/unit_tests/breaking_news/breaking_news.test.js new file mode 100644 index 00000000..1742d8eb --- /dev/null +++ b/tests/unit_tests/breaking_news/breaking_news.test.js @@ -0,0 +1,82 @@ +const chai = require(`chai`); +const expect = chai.expect; + +const { try_to_break_the_news_gently } = require(`../../../lib/breaking_news.js`); + +/** Example of process.argv: + * [ + * `/Users/me/.nvm/versions/node/v18.17.0/bin/node`, + * `/Users/me/code/alkiln/lib/breaking_news.js`, + * `--alkiln_desired_version=6.20.0'`, + * `--alkip_version=1.0.0` + * ] + * */ + +describe(`try_to_break_the_news_gently() with`, function () { + + // ===== Announcements ===== + + describe(`desired ALKiln at 5.15.0 and ALKiP at 1.3.1`, function () { + // This will never show in real life + it(`to return an announcement that includes "ALK0280"`, function () { + process.argv = [`node_path`, `file_path`, `--alkiln_desired_version=5.15.0`, `--alkip_version=1.3.1`]; + let news = try_to_break_the_news_gently(); + expect( news ).to.include(`ALK0280`); + }); + + }); + + describe(`current ALKiln at 5.14.1 and ALKiP at 1.4.0`, function () { + + it(`to return an announcement that includes "ALK0281"`, function () { + process.argv = [`node_path`, `file_path`, `--alkiln_current_version=5.14.1`, `--alkip_version=1.4.0`]; + let news = try_to_break_the_news_gently(); + expect( news ).to.include(`ALK0281`); + }); + + }); + + describe(`experimental versions of different formats for current ALKiln at 5.14.1-feat and ALKiP with a version greater than 1.3.1 purely because of the experimental version format at 1.3.1-b`, function () { + + it(`to return an announcement that includes "ALK0281"`, function () { + process.argv = [`node_path`, `file_path`, `--alkiln_current_version=5.14.1-feat`, `--alkip_version=1.3.1-b`]; + let news = try_to_break_the_news_gently(); + expect( news ).to.include(`ALK0281`); + }); + + }); + + + // ===== Non-announcements ===== + + describe(`desired ALKiln at 5.14.1 and ALKiP at 1.3.1`, function () { + + it(`to return no announcement ("ALK0278")`, function () { + process.argv = [`node_path`, `file_path`, `--alkiln_desired_version=5.14.1`, `--alkip_version=1.3.1`]; + let news = try_to_break_the_news_gently(); + expect( news ).to.include(`ALK0278`); + }); + + }); + + describe(`current ALKiln at 5.15.2 and ALKiP at 1.3.0`, function () { + + it(`to return no announcement ("ALK0278")`, function () { + process.argv = [`node_path`, `file_path`, `--alkiln_current_version=5.15.2`, `--alkip_version=1.3.0`]; + let news = try_to_break_the_news_gently(); + expect( news ).to.include(`ALK0278`); + }); + + }); + + describe(`experimental versions of different formats for current ALKiln at 5.15.2-3 and ALKiP at 1.3.0-5-2`, function () { + + it(`the version after the "-" doesn't get used as numbers and returns no announcement ("ALK0278")`, function () { + process.argv = [`node_path`, `file_path`, `--alkiln_current_version=5.15.2-3`, `--alkip_version=1.3.0-5-2`]; + let news = try_to_break_the_news_gently(); + expect( news ).to.include(`ALK0278`); + }); + + }); + +});