diff --git a/bazel/pip_package_rule/BUILD.bazel b/bazel/pip_package_rule/BUILD.bazel index d9b129c05..cbf3c64f3 100644 --- a/bazel/pip_package_rule/BUILD.bazel +++ b/bazel/pip_package_rule/BUILD.bazel @@ -21,6 +21,12 @@ exports_files( # A trampoline for the `pip_package` Bazel rule to invoke Python's `build` tool. # Should only be used via the `pip_package` Bazel rule. +py_binary( + name = "strip_requirements", + srcs = ["strip_requirements.py"], + visibility = ["//visibility:public"], +) + py_binary( name = "build_trampoline", srcs = ["build_trampoline.py"], diff --git a/bazel/pip_package_rule/build_trampoline.py b/bazel/pip_package_rule/build_trampoline.py index 1aa9e078b..4c9b8e7b5 100644 --- a/bazel/pip_package_rule/build_trampoline.py +++ b/bazel/pip_package_rule/build_trampoline.py @@ -76,6 +76,7 @@ def find_package_name(line: str) -> str: parser.add_argument( "--requirements-txt", type=str, + action="append", help="the path to the requirements.txt file", required=True, ) @@ -114,17 +115,19 @@ def find_package_name(line: str) -> str: normalize_package_name(dep) for dep in args.verify_dependency_in_requirements ] - with open(args.requirements_txt) as requirements_txt: - for line in requirements_txt: - if line.startswith("#"): - continue - dependency = normalize_package_name(find_package_name(line)) - try: - # This dependency is no longer missing! - missing_dependencies.remove(dependency) - except ValueError: - # Turns out we don't need this dependency. That's fine. - pass + for requirements_txt_path in args.requirements_txt: + with open(requirements_txt_path) as requirements_txt: + for line in requirements_txt: + if line.startswith("#"): + continue + dependency = normalize_package_name(find_package_name(line)) + try: + # This dependency is no longer missing! + missing_dependencies.remove(dependency) + except ValueError: + # Turns out we don't need this dependency. That's + # fine. + pass if len(missing_dependencies) > 0: raise MissingDependenciesError( f"Expected dependencies {missing_dependencies} to be in the " diff --git a/bazel/pip_package_rule/pip_package.bzl b/bazel/pip_package_rule/pip_package.bzl index cc91058ef..8fca0a931 100644 --- a/bazel/pip_package_rule/pip_package.bzl +++ b/bazel/pip_package_rule/pip_package.bzl @@ -560,16 +560,56 @@ def _pip_package_impl(ctx): staged_requirements_txt = ctx.actions.declare_file( "%s/requirements.txt" % STAGING_DIRECTORY_NAME, ) - ctx.actions.run_shell( - mnemonic = "StageRequirements", - command = "cp %s %s" % ( + extras_requirements_txts = {} + for extra_target, extra_name in ctx.attr.extras.items(): + extra_files = extra_target[DefaultInfo].files.to_list() + if len(extra_files) != 1: + fail("Expected exactly one requirements file for extra '%s'" % + extra_name) + extras_requirements_txts[extra_name] = extra_files[0] + if extras_requirements_txts: + strip_arguments = [ + "--requirements-txt", input_requirements_txt.path, + "--output", staged_requirements_txt.path, - ), - inputs = [input_requirements_txt], - outputs = [staged_requirements_txt], - ) + ] + for extra_file in extras_requirements_txts.values(): + strip_arguments.append( + "--extra-requirements-txt=%s" % extra_file.path, + ) + ctx.actions.run( + mnemonic = "StripExtrasRequirements", + executable = ctx.executable._strip_requirements_tool, + arguments = strip_arguments, + inputs = [input_requirements_txt] + + extras_requirements_txts.values(), + outputs = [staged_requirements_txt], + ) + else: + ctx.actions.run_shell( + mnemonic = "StageRequirements", + command = "cp %s %s" % ( + input_requirements_txt.path, + staged_requirements_txt.path, + ), + inputs = [input_requirements_txt], + outputs = [staged_requirements_txt], + ) metadata_files.append(staged_requirements_txt) + staged_extras_requirements_txts = [] + for extra_name, extra_file in extras_requirements_txts.items(): + staged_extra = ctx.actions.declare_file( + "%s/requirements-%s.txt" % (STAGING_DIRECTORY_NAME, extra_name), + ) + ctx.actions.run_shell( + mnemonic = "StageExtraRequirements", + command = "cp %s %s" % (extra_file.path, staged_extra.path), + inputs = [extra_file], + outputs = [staged_extra], + ) + metadata_files.append(staged_extra) + staged_extras_requirements_txts.append(staged_extra) ### pyproject.toml # The `pyproject.toml` file is the main configuration file that will control @@ -623,14 +663,39 @@ def _pip_package_impl(ctx): version = ctx.attr.version license = ctx.attr.license pyproject_toml_file = ctx.actions.declare_file("%s/pyproject.toml" % STAGING_DIRECTORY_NAME) + entry_points = "" + for group, entries in ctx.attr.entry_points.items(): + entry_points += "[project.entry-points.%s]\n" % group + for entry in entries: + name, separator, module = entry.partition("=") + if separator == "": + fail("Expected an entry_points entry of the form " + + "'name = module', but got '%s'" % entry) + entry_points += '%s = "%s"\n' % (name.strip(), module.strip()) + + dynamic_fields = '["dependencies"]' + optional_dependencies = "" + if extras_requirements_txts: + dynamic_fields = '["dependencies", "optional-dependencies"]' + optional_dependencies = ( + "[tool.setuptools.dynamic.optional-dependencies]\n" + ) + for extra_name in extras_requirements_txts.keys(): + optional_dependencies += ( + '%s = { file = ["requirements-%s.txt"] }\n' % + (extra_name, extra_name) + ) ctx.actions.expand_template( template = ctx.file._toml_template, output = pyproject_toml_file, substitutions = { "{CLASSIFIERS}": str(classifiers), "{DESCRIPTION}": ctx.attr.description, + "{DYNAMIC_FIELDS}": dynamic_fields, + "{ENTRY_POINTS}": entry_points, "{LICENSE}": license, "{NAME}": ctx.attr.distribution_name, + "{OPTIONAL_DEPENDENCIES}": optional_dependencies, "{PACKAGE_DATA}": package_data, "{SCRIPTS}": scripts, "{VERSION}": version, @@ -711,8 +776,11 @@ def _pip_package_impl(ctx): output_directory, "--verify-python-version", ctx.attr._python_version, - "--requirements-txt", - staged_requirements_txt.path, + ] + [ + "--requirements-txt=%s" % requirements.path + for requirements in ( + [staged_requirements_txt] + staged_extras_requirements_txts + ) ] + [ "--verify-dependency-in-requirements=%s" % dependency for dependency in pypi_dependencies.keys() @@ -768,6 +836,23 @@ _pip_package = rule( doc = "The name of the distribution to generate.", mandatory = True, ), + "entry_points": attr.string_list_dict( + default = {}, + doc = "The entry points this pip package advertises: " + + "each key is an entry-point group, e.g. " + + "'pytest11', and each value that group's " + + "'name = module' entries.", + ), + "extras": attr.label_keyed_string_dict( + allow_files = True, + default = {}, + doc = "Optional-dependency extras: each key is a " + + "requirements file and each value the extra's name; " + + "the file's packages ship as that extra instead of " + + "as dependencies, and every one of them must also " + + "be in `requirements_txt`, which stays the " + + "complete list.", + ), "license": attr.string( doc = "The license to use for the pip package.", mandatory = True, @@ -823,6 +908,11 @@ _pip_package = rule( default = Label("//bazel/pip_package_rule:setup.py.template"), allow_single_file = True, ), + "_strip_requirements_tool": attr.label( + default = Label("//bazel/pip_package_rule:strip_requirements"), + executable = True, + cfg = "exec", + ), "_toml_template": attr.label( # A pointer to the template for the `pyproject.toml` file. default = Label("//bazel/pip_package_rule:pyproject.toml.template"), diff --git a/bazel/pip_package_rule/pyproject.toml.template b/bazel/pip_package_rule/pyproject.toml.template index ddee217c2..b9d51df5c 100644 --- a/bazel/pip_package_rule/pyproject.toml.template +++ b/bazel/pip_package_rule/pyproject.toml.template @@ -3,7 +3,7 @@ name = "{NAME}" version = "{VERSION}" # TODO: See https://github.com/reboot-dev/mono/issues/4200. requires-python = ">=3.10,<3.13" -dynamic = ["dependencies"] +dynamic = {DYNAMIC_FIELDS} readme = "README.md" description = "{DESCRIPTION}" classifiers = {CLASSIFIERS} @@ -12,6 +12,8 @@ license = "{LICENSE}" [tool.setuptools.dynamic] dependencies = { file = "requirements.txt" } +{OPTIONAL_DEPENDENCIES} + [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" @@ -19,6 +21,8 @@ build-backend = "setuptools.build_meta" [project.scripts] {SCRIPTS} +{ENTRY_POINTS} + [tool.setuptools] # The default settings understand our `src/` layout and will auto-discover the # Python packages we've placed there. We don't need to specify them here. diff --git a/bazel/pip_package_rule/strip_requirements.py b/bazel/pip_package_rule/strip_requirements.py new file mode 100644 index 000000000..ac5cba08c --- /dev/null +++ b/bazel/pip_package_rule/strip_requirements.py @@ -0,0 +1,85 @@ +"""Writes a requirements file with the packages of the given extras +requirements files removed, so that those packages ship as a wheel's +optional-dependency extras instead of its dependencies.""" + +import argparse +import re + + +def normalize_package_name(name: str) -> str: + """ + Normalizes the package name per + https://packaging.python.org/en/latest/specifications/name-normalization/#normalization + """ + return re.sub(r"[-_.]+", "-", name).lower() + + +def find_package_name(line: str) -> str: + """ + Given a line like: + ``` + my-cool_pAcKaG3.n4me==1.2.3 # some comment. + ``` + Returns "my-cool.pAcKaG3.n4me". + """ + package_name = re.match("^([\\w._-]+)", line, flags=re.IGNORECASE) + if package_name is None: + raise ValueError(f"Could not find package name in line: '{line}'") + + return package_name.group(1) + + +def requirement_packages(filename: str) -> set[str]: + """The normalized package names the requirements file pins.""" + packages = set() + with open(filename) as requirements: + for line in requirements: + line = line.strip() + if not line or line.startswith("#"): + continue + packages.add(normalize_package_name(find_package_name(line))) + return packages + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--requirements-txt", type=str, required=True) + parser.add_argument("--output", type=str, required=True) + parser.add_argument( + "--extra-requirements-txt", + type=str, + action="append", + default=[], + help="requirements file whose packages become an extra, so " + "they are removed from the output; every one of its packages " + "must be present in --requirements-txt", + ) + args = parser.parse_args() + + extras_packages = set() + for filename in args.extra_requirements_txt: + extras_packages.update(requirement_packages(filename)) + + missing = extras_packages - requirement_packages(args.requirements_txt) + if missing: + raise ValueError( + f"Expected the extras packages {sorted(missing)} to also be " + f"in '{args.requirements_txt}', which stays the complete " + "list, but they were not" + ) + + with open(args.requirements_txt) as requirements: + with open(args.output, "w") as output: + for line in requirements: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + package = normalize_package_name( + find_package_name(stripped) + ) + if package in extras_packages: + continue + output.write(line) + + +if __name__ == "__main__": + main() diff --git a/documentation/docs/learn_more/errors.mdx b/documentation/docs/learn_more/errors.mdx index be449f0c2..eeca49ce2 100644 --- a/documentation/docs/learn_more/errors.mdx +++ b/documentation/docs/learn_more/errors.mdx @@ -215,10 +215,6 @@ Here's an example of how the `OverdraftError` can be caught in a type-safe way: - - - ```py try: await account.withdraw(context, amount=65) @@ -232,8 +228,6 @@ except Account.WithdrawAborted as aborted: ) raise ``` - - - - ```py response = await Account.WelcomeEmailTask.retrieve( context, task_id=welcome_email_task_id, ) ``` - - ```ts diff --git a/documentation/docs/learn_more/testing.md b/documentation/docs/learn_more/testing.md index cfc3ad4cf..3d77e757f 100644 --- a/documentation/docs/learn_more/testing.md +++ b/documentation/docs/learn_more/testing.md @@ -11,10 +11,6 @@ To write a test, you can use the `reboot.aio.tests.Reboot` class. This allows you to start your servicer, create a context, and call the method you want to test. - - - ```py async def asyncSetUp(self) -> None: self.rbt = Reboot() @@ -48,8 +44,6 @@ async def test_chat_room(self) -> None: ) ``` - - #### Setting Secrets Some servicers may use [secrets](/learn_more/secrets) for connecting diff --git a/mypy.ini b/mypy.ini index e7aac1df1..278613802 100644 --- a/mypy.ini +++ b/mypy.ini @@ -56,6 +56,8 @@ ignore_missing_imports = True [mypy-envoy.*] ignore_missing_imports = True follow_imports = skip +[mypy-gherkin.*] +ignore_missing_imports = True [mypy-git.*] ignore_missing_imports = True [mypy-google] @@ -124,6 +126,10 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pyprctl.*] ignore_missing_imports = True +[mypy-pytest] +ignore_missing_imports = True +[mypy-pytest_bdd.*] +ignore_missing_imports = True [mypy-requests.*] ignore_missing_imports = True [mypy-six.*] @@ -154,6 +160,9 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pydantic_core.*] ignore_missing_imports = True +[mypy-tests.reboot.bdd.pydantic.*] +ignore_missing_imports = True +disable_error_code = attr-defined [mypy-tests.reboot.pydantic.*] ignore_missing_imports = True disable_error_code = attr-defined @@ -172,6 +181,10 @@ disable_error_code = attr-defined ignore_missing_imports = True [mypy-httpx.*] ignore_missing_imports = True +[mypy-jsonpath_ng.*] +ignore_missing_imports = True +[mypy-json5.*] +ignore_missing_imports = True [mypy-reboot.*] ignore_missing_imports = True [mypy-rbt.*] diff --git a/rbt/dashboard/v1/BUILD.bazel b/rbt/dashboard/v1/BUILD.bazel index 50c808db8..6a6d6e134 100644 --- a/rbt/dashboard/v1/BUILD.bazel +++ b/rbt/dashboard/v1/BUILD.bazel @@ -17,6 +17,8 @@ proto_library( "@com_github_reboot_dev_reboot//rbt/v1alpha1:options_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:api_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:schema_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:feature_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:grammar_proto", "@com_google_protobuf//:struct_proto", "@com_google_protobuf//:timestamp_proto", ], @@ -41,6 +43,8 @@ js_proto_library( "@com_github_reboot_dev_reboot//rbt/v1alpha1:options_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:api_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:schema_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:feature_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:grammar_proto", "@com_google_protobuf//:descriptor_proto", "@com_google_protobuf//:timestamp_proto", ], @@ -70,6 +74,8 @@ js_reboot_react_library( "@com_github_reboot_dev_reboot//rbt/v1alpha1:options_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:api_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:schema_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:feature_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:grammar_proto", "@com_google_protobuf//:descriptor_proto", "@com_google_protobuf//:timestamp_proto", ], diff --git a/rbt/dashboard/v1/dashboard.proto b/rbt/dashboard/v1/dashboard.proto index 93d56f3f1..4e51e82f3 100644 --- a/rbt/dashboard/v1/dashboard.proto +++ b/rbt/dashboard/v1/dashboard.proto @@ -6,6 +6,7 @@ import "google/protobuf/timestamp.proto"; import "rbt/v1alpha1/options.proto"; import "rbt/v1alpha1/api/api.proto"; import "rbt/v1alpha1/api/schema.proto"; +import "rbt/v1alpha1/bdd/feature.proto"; //////////////////////////////////////////////////////////////////////// @@ -66,6 +67,11 @@ message Dashboard { // code is missing or out of date, which is what suggests running // `rbt generate`. map generated = 8; + + // What each of the developer's `.feature` files declares, keyed + // by the file's path relative to the working directory, as of the + // last write. + map features = 9; } message DashboardGetRequest {} @@ -94,6 +100,8 @@ message DashboardGetResponse { // The worst reason over all the API files, and absent when // nothing says to run `rbt generate`. optional NeedsGenerateReason needs_generate_reason = 8; + + map features = 9; } message DashboardUpdateApiRequest { @@ -388,6 +396,17 @@ message DashboardWatchCodeRequest {} message DashboardWatchCodeResponse {} +message DashboardUpdateBehaviorsRequest { + // Keyed the way `Dashboard.features` is. + map features = 1; +} + +message DashboardUpdateBehaviorsResponse {} + +message DashboardWatchBehaviorsRequest {} + +message DashboardWatchBehaviorsResponse {} + //////////////////////////////////////////////////////////////////////// // One servicer found in the developer's application, and the state @@ -644,6 +663,23 @@ service DashboardMethods { option (rbt.v1alpha1.method).workflow = { }; } + + // Replaces what the developer's `.feature` files declare. Its own + // writer, so that recording behaviors never writes back what the + // other two updates record. + rpc UpdateBehaviors(DashboardUpdateBehaviorsRequest) + returns (DashboardUpdateBehaviorsResponse) { + option (rbt.v1alpha1.method).writer = { + }; + } + + // Watches the developer's `.feature` files for as long as the + // dashboard application runs, parsing each one that changes. + rpc WatchBehaviors(DashboardWatchBehaviorsRequest) + returns (DashboardWatchBehaviorsResponse) { + option (rbt.v1alpha1.method).workflow = { + }; + } } //////////////////////////////////////////////////////////////////////// diff --git a/rbt/v1alpha1/bdd/BUILD.bazel b/rbt/v1alpha1/bdd/BUILD.bazel new file mode 100644 index 000000000..7d5e2ea0e --- /dev/null +++ b/rbt/v1alpha1/bdd/BUILD.bazel @@ -0,0 +1,47 @@ +load("@com_github_grpc_grpc//bazel:python_rules.bzl", "py_proto_library") +load("@com_github_reboot_dev_reboot//reboot:rules.bzl", "js_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") + +proto_library( + name = "grammar_proto", + srcs = [":grammar.proto"], + visibility = ["//visibility:public"], +) + +py_proto_library( + name = "grammar_py_proto", + visibility = ["//visibility:public"], + deps = [":grammar_proto"], +) + +js_proto_library( + name = "grammar_js_proto", + package_json = ":package.json", + proto = ":grammar.proto", + proto_deps = [":grammar_proto"], + visibility = ["//visibility:public"], +) + +proto_library( + name = "feature_proto", + srcs = [":feature.proto"], + visibility = ["//visibility:public"], + deps = [":grammar_proto"], +) + +py_proto_library( + name = "feature_py_proto", + visibility = ["//visibility:public"], + deps = [":feature_proto"], +) + +js_proto_library( + name = "feature_js_proto", + package_json = ":package.json", + proto = ":feature.proto", + proto_deps = [ + ":feature_proto", + ":grammar_proto", + ], + visibility = ["//visibility:public"], +) diff --git a/rbt/v1alpha1/bdd/feature.proto b/rbt/v1alpha1/bdd/feature.proto new file mode 100644 index 000000000..1a42e88ab --- /dev/null +++ b/rbt/v1alpha1/bdd/feature.proto @@ -0,0 +1,165 @@ +syntax = "proto3"; + +package rbt.v1alpha1.bdd; + +import "rbt/v1alpha1/bdd/grammar.proto"; + +//////////////////////////////////////////////////////////////////////// + +// What one of the developer's `.feature` files declares: the +// feature, the scenarios that belong to it directly, and the rules +// it groups the rest of its scenarios under. +message Feature { + // The keyword as written, e.g. "Feature"; Gherkin lets a file + // write its keywords in its own language. + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + // The prose under the heading, dedented; absent for none. + optional string description = 3; + + // Tag names as written, `@` included, e.g. "@wip". + repeated string tags = 4; + + // The steps every scenario in the file begins with; absent when + // the file declares none. + optional Background background = 5; + + // The scenarios before the first rule, which belong to the + // feature directly: once a rule starts, every scenario after it + // belongs to a rule. + repeated Scenario scenarios = 6; + + repeated Rule rules = 7; + + // Why the file could not be parsed, when it could not be; a + // half-written file is the normal cause while someone is typing. + // A feature carrying an error carries nothing else. + optional string error = 8; +} + +// One business rule of a feature, illustrated by the scenarios +// grouped under it. +message Rule { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + optional string description = 3; + + repeated string tags = 4; + + // The steps every scenario under this rule begins with, run after + // the feature's own background; absent when the rule declares + // none. + optional Background background = 5; + + repeated Scenario scenarios = 6; +} + +// The steps a feature's or a rule's scenarios share, run before +// each scenario's own. +message Background { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + optional string description = 3; + + repeated Step steps = 4; +} + +// One scenario, which runs as one test. The keyword may be +// "Scenario", its synonym "Example", or "Scenario Outline" for one +// templated over examples tables. +message Scenario { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + optional string description = 3; + + repeated string tags = 4; + + repeated Step steps = 5; + + // The line the scenario is declared on, counting from one. + uint32 line = 6; + + // The examples tables a "Scenario Outline" is templated over; + // empty for a plain scenario. + repeated Examples examples = 7; + + // The videos of the scenario's last run in a browser, one per user + // whose browser it drove, kept beside the feature file (see + // `reboot.bdd.recordings`); empty when no run of the scenario as + // it is now was recorded. + repeated Video videos = 8; + + // Whether recordings exist only of an earlier version of the + // scenario: it, or a background it runs under, changed since its + // last run in a browser was recorded. + bool recordings_stale = 9; +} + +// The video of one user's browser in a scenario's last run. +message Video { + // The user whose browser the video shows, as the video names them. + string user = 1; + + // The video's path relative to the working directory. + string path = 2; +} + +// One step of a scenario or a background. +message Step { + // The keyword as written but without its trailing space, e.g. + // "Given" or "And". + string keyword = 1; + + string text = 2; + + // The step's doc string argument, when it has one. + optional string doc_string = 3; + + // The step's data table argument, when it has one. + optional Table table = 4; + + // The text's syntax tree under the built-in steps' grammar; absent + // for a step the grammar does not define, such as one the + // application defines itself. + optional BuiltInSyntax built_in = 5; + + // The line the step is written on, counting from one. + uint32 line = 6; + + // The screenshot taken after the step in the last recorded run of + // its scenario as it is now, as a path relative to the working + // directory, kept beside the feature file (see + // `reboot.bdd.recordings`); absent when none was taken. + optional string screenshot = 7; +} + +// Rows of cells: a step's data table, or an examples table. +message Table { + message Row { + repeated string cells = 1; + } + + repeated Row rows = 1; +} + +// One examples table of a "Scenario Outline", its header row first. +message Examples { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + Table table = 3; +} diff --git a/rbt/v1alpha1/bdd/grammar.proto b/rbt/v1alpha1/bdd/grammar.proto new file mode 100644 index 000000000..a9371fc14 --- /dev/null +++ b/rbt/v1alpha1/bdd/grammar.proto @@ -0,0 +1,417 @@ +syntax = "proto3"; + +package rbt.v1alpha1.bdd; + +//////////////////////////////////////////////////////////////////////// + +// A value as a step writes it: JSON (JSON5, so keys need no quotes), +// parsed against the property it is set on or asserted against when +// the scenario runs, since what `1` means depends on that property's +// type. May hold a variable, ``, whose value is spliced in +// first: a column of a Scenario Outline's Examples table, or a value +// a step before it saved; a value that is only a variable is the +// saved value itself. +message Value { + string json = 1; +} + +// The state a step acts on: 'the `Account` for "alice"'. +message State { + // The state type as the step spells it, e.g. `Account`, which a + // step may qualify with its package. + string type = 1; + + // The id as written, without its quotes; may be a variable, + // ``, whose value is the id. + string id = 2; +} + +// A `path=value` clause of a call's `with`: the property set and +// what it is set to. +message Assignment { + string path = 1; + + Value value = 2; +} + +// A `path=value` clause of an asserting list: the property equals +// the value. +message Equals { + string path = 1; + + Value value = 2; +} + +// A `path` containing `argument` clause: a substring of a string, an +// element of a list, or a key of a map. +message Containing { + string path = 1; + + Value argument = 2; +} + +// A `path` of length `length` clause: the length of a string, list, +// or map. +message OfLength { + string path = 1; + + Value length = 2; +} + +// One clause of an asserting list. +message Assertion { + oneof assertion { + Equals equals = 1; + Containing containing = 2; + OfLength of_length = 3; + } +} + +// A `path` saved as `name` clause: the property read and the name it +// is saved under. +message Save { + string path = 1; + + string name = 2; +} + +//////////////////////////////////////////////////////////////////////// + +// 'the application is up', or 'the "name" application is up' for one +// of several. +message ApplicationIsUp { + optional string name = 1; +} + +// '"alice" is an authenticated user': declares a user the scenario's +// steps may call as, minting a token for the user id. +message IsAnAuthenticatedUser { + string user_id = 1; +} + +// '"admin" has the bearer token "..."': declares a user by a token +// the scenario obtained some other way. +message HasBearerToken { + string user_id = 1; + + string bearer_token = 2; +} + +// '"bob" is an unauthenticated user': declares a user whose calls +// carry no token. +message IsAnUnauthenticatedUser { + string user_id = 1; +} + +// 'as "alice", a shared context'. +message SharedContext { + // The user the shared context's calls are made as. + string user = 1; +} + +// '"alice" creates an `Account` of "alice" via `open` with ...'. +message CreatesVia { + State state = 1; + + string method = 2; + + repeated Assignment assignments = 3; + + // The user the call is made as. + string user = 4; +} + +// '"alice" does a `deposit` on `Account` of "alice" with ...', or +// '"alice" spawns a `deposit` on `Account` of "alice" with ... and +// saves its task id as `name`'. +message Does { + State state = 1; + + string method = 2; + + repeated Assignment assignments = 3; + + // The name the spawned task's id is saved under; absent for a + // call that is done, not spawned. + optional string task_id_saved_as = 4; + + // The user the call is made as. + string user = 5; +} + +// '"alice" attempts a `withdraw` on `Account` of "alice" with ...'. +message Attempts { + State state = 1; + + string method = 2; + + repeated Assignment assignments = 3; + + // The user the call is made as. + string user = 4; +} + +// '"alice" awaits the `deposit` task "" on `Account` within 30 +// seconds'. +message AwaitsTask { + string method = 1; + + // The name the task's id was saved under. + string task_id_saved_as = 2; + + string state_type = 3; + + double seconds = 4; + + // The user the call is made as. + string user = 5; +} + +// 'the attempt aborts with `OverdraftError` with ...'. +message AttemptAbortsWith { + string error_type = 1; + + repeated Assertion assertions = 2; +} + +// '`balance` on the `Account` for "alice" has ...': a reader, whose +// response is asserted on. A writer, transaction, or workflow is +// called with `Gets` and its result asserted with `ResultHas`. +message Has { + string method = 1; + + State state = 2; + + repeated Assertion assertions = 3; + + // The user the call is made as. + string user = 4; +} + +// '`balance` on the `Account` for "alice" eventually has ... within +// 30 seconds': a reader, read reactively until its response +// satisfies the assertions or the bound passes. +message EventuallyHas { + string method = 1; + + State state = 2; + + repeated Assertion assertions = 3; + + double seconds = 4; + + // The user the call is made as. + string user = 5; +} + +// '`get` on the `Account` for "alice" has `owner` saved as `o`': a +// reader, whose response is saved from. +message HasSavedAs { + string method = 1; + + State state = 2; + + repeated Save saves = 3; + + // The user the call is made as. + string user = 4; +} + +// '`balance` on the `Account` for "alice" aborts with +// `OverdraftError` with ...': a reader that aborts. A writer, +// transaction, or workflow that aborts is called with `Attempts` +// and its abort asserted with `AttemptAbortsWith`. +message AbortsWith { + string method = 1; + + State state = 2; + + string error_type = 3; + + repeated Assertion assertions = 4; + + // The user the call is made as. + string user = 5; +} + +// 'the result has ...'. +message ResultHas { + repeated Assertion assertions = 1; +} + +// An element of the web app a step names by what it is and what it +// says: 'the "Open Account" button'. The name is the element's +// accessible name as written, which may hold a variable. +message Element { + // What an element may be: the ARIA roles a step may name, each + // written in a step as its name in lower case. + enum Role { + ROLE_UNSPECIFIED = 0; + BUTTON = 1; + LINK = 2; + TAB = 3; + CHECKBOX = 4; + RADIO = 5; + MENUITEM = 6; + OPTION = 7; + ROW = 8; + TABLE = 9; + } + + Role role = 1; + + string name = 2; +} + +// '"alice" opens the web app', optionally 'at "/path"'. +message OpensWebApp { + string user = 1; + + optional string path = 2; +} + +// '"alice" clicks the "Open Account" button in the web app'. +message ClicksInWebApp { + string user = 1; + + Element element = 2; +} + +// '"alice" fills "Amount ($)" in the web app with `250`': the field +// named by its label. +message FillsInWebApp { + string user = 1; + + string label = 2; + + Value value = 3; +} + +// '"alice" selects "" in "From Account" in the web app': the +// option, by its text, of the select named by its label. +message SelectsInWebApp { + string user = 1; + + string option = 2; + + string label = 3; +} + +// '"alice" checks "Remember me" in the web app', or unchecks. +message ChecksInWebApp { + string user = 1; + + string label = 2; + + bool checked = 3; +} + +// '"alice" presses "Enter" in the web app'. +message PressesInWebApp { + string user = 1; + + string key = 2; +} + +// '"alice" sees "$1000" in the web app', optionally 'in the "Your +// Accounts" table' before 'in the web app'; 'does not see' for the +// text's absence; 'eventually sees ... within 10 seconds' for text +// that arrives. +message SeesInWebApp { + string user = 1; + + string text = 2; + + // The element the text is looked for in; absent for the page. + optional Element within = 3; + + bool negated = 4; + + // Absent for a plain 'sees'. + optional double seconds = 5; +} + +// '"alice" sees the "Transfer Funds" button in the web app is +// disabled', or enabled. +message SeesEnabledInWebApp { + string user = 1; + + Element element = 2; + + bool enabled = 3; +} + +// '"alice" sees the web app at "/accounts"'. +message SeesWebAppAt { + string user = 1; + + string path = 2; +} + +// '"alice" saves the text of the "account-id" element in the web app +// as `account_id`': the element named by its test id. +message SavesTextInWebAppAs { + string user = 1; + + string test_id = 2; + + string name = 3; +} + +// '"alice" is signed in to the web app', optionally 'with their user +// id saved as `alice_user_id`': the browser's session is a signed-in +// user, whom "alice" calls as from here on. +message IsSignedInToWebApp { + string user = 1; + + // The name the signed-in user's id is saved under, if any. + optional string saved_as = 2; +} + +// '"alice" is signed out of the web app': the browser's session is +// nobody, and "alice" calls with no token from here on. +message IsSignedOutOfWebApp { + string user = 1; +} + +// 'the resulting `account_id` is saved as `alice_account_id`'. +message ResultingIsSavedAs { + Save save = 1; +} + +// The syntax tree of a step's text under the grammar of the built-in +// `reboot.bdd` steps: which built-in step it is, and the parts the +// step takes. Only a text one of the built-in steps matches has one; +// a step an application defines itself does not. +message BuiltInSyntax { + oneof step { + ApplicationIsUp application_is_up = 1; + IsAnAuthenticatedUser is_an_authenticated_user = 2; + HasBearerToken has_bearer_token = 3; + SharedContext shared_context = 4; + CreatesVia creates_via = 5; + Does does = 6; + Attempts attempts = 7; + AwaitsTask awaits_task = 8; + AttemptAbortsWith attempt_aborts_with = 9; + Has has = 10; + EventuallyHas eventually_has = 11; + HasSavedAs has_saved_as = 12; + AbortsWith aborts_with = 13; + ResultHas result_has = 14; + ResultingIsSavedAs resulting_is_saved_as = 15; + OpensWebApp opens_web_app = 16; + ClicksInWebApp clicks_in_web_app = 17; + FillsInWebApp fills_in_web_app = 18; + SelectsInWebApp selects_in_web_app = 19; + ChecksInWebApp checks_in_web_app = 20; + PressesInWebApp presses_in_web_app = 21; + SeesInWebApp sees_in_web_app = 22; + SeesEnabledInWebApp sees_enabled_in_web_app = 23; + SeesWebAppAt sees_web_app_at = 24; + SavesTextInWebAppAs saves_text_in_web_app_as = 25; + IsAnUnauthenticatedUser is_an_unauthenticated_user = 26; + IsSignedInToWebApp is_signed_in_to_web_app = 27; + IsSignedOutOfWebApp is_signed_out_of_web_app = 28; + } +} diff --git a/rbt/v1alpha1/bdd/package.json b/rbt/v1alpha1/bdd/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/rbt/v1alpha1/bdd/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/reboot/BUILD.bazel b/reboot/BUILD.bazel index 8dc114207..688fa61fa 100644 --- a/reboot/BUILD.bazel +++ b/reboot/BUILD.bazel @@ -10,6 +10,7 @@ exports_files([ "LICENSE.txt", "versions.bzl", "requirements.in", + "requirements-pytest-bdd.in", ]) py_library( @@ -498,6 +499,14 @@ compile_pip_requirements( requirements_txt = ":requirements_lock.txt", ) +py_library( + name = "bdd_plugin_py", + srcs = ["bdd_plugin.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = ["//reboot/bdd:steps_py"], +) + py_library( name = "python_std", deps = [ @@ -532,6 +541,7 @@ py_library( ], deps = [ ":api_py", + ":bdd_plugin_py", ":protobuf_py", ":protoc_gen_es_with_deps_py", ":protoc_gen_reboot_nodejs_boilerplate_py", @@ -543,6 +553,9 @@ py_library( ":python_std", ":python_thirdparty", "//reboot/aio:python", + "//reboot/bdd:recording_py", + "//reboot/bdd:vite_py", + "//reboot/bdd:web_py", "//reboot/cli:main_py", "//reboot/dashboard/backend:main_py", "//reboot/mcp:python", @@ -562,6 +575,12 @@ pip_package( # coincidence and does not refer to the company name. name = "reboot", description = "The Reboot library", + entry_points = { + "pytest11": ["reboot-bdd = reboot.bdd_plugin"], + }, + extras = { + "//reboot:requirements-pytest-bdd.in": "pytest-bdd", + }, license = "Apache-2.0", license_txt = ":LICENSE.txt", readme_md = "//:README.md", diff --git a/reboot/aio/BUILD.bazel b/reboot/aio/BUILD.bazel index 754938a7b..18b0ec065 100644 --- a/reboot/aio/BUILD.bazel +++ b/reboot/aio/BUILD.bazel @@ -344,6 +344,7 @@ py_library( ":tracing_py", "//reboot:naming_py", "//reboot:run_environments_py", + "//reboot:wait_for_tasks_py", "//reboot/aio:servers_py", "//reboot/aio/auth:authorizers_py", "//reboot/aio/auth:token_verifiers_py", diff --git a/reboot/aio/auth/__init__.py b/reboot/aio/auth/__init__.py index b6cd704a0..abbb45bbc 100644 --- a/reboot/aio/auth/__init__.py +++ b/reboot/aio/auth/__init__.py @@ -23,6 +23,12 @@ REFRESH_COOKIE_NAME = "rbt_refresh" PENDING_COOKIE_NAME = "rbt_oauth_pending" +# The path of the OAuth server's endpoint answering who a browser +# session is, from the `SESSION_COOKIE_NAME` cookie: whether it is +# signed in, and if so the user id and the access token the SPA +# sends as its bearer. +WHOAMI_PATH = "/__/oauth/whoami" + def __getattr__(name: str) -> Any: """Lazily re-export the OAuth token public API so diff --git a/reboot/aio/auth/development_login_page.html.j2 b/reboot/aio/auth/development_login_page.html.j2 index a8a91f8cf..308814382 100644 --- a/reboot/aio/auth/development_login_page.html.j2 +++ b/reboot/aio/auth/development_login_page.html.j2 @@ -116,8 +116,8 @@

Who do you want to be?

pick an identity to log in as

{% for account in accounts %} -
+ + + )} + {step.builtIn !== undefined ? ( + + ) : ( + + )} + {step.docString !== undefined && ( +
+            {step.docString}
+          
+ )} + {step.table !== undefined && } +
+
+ ); +}; + +// One row of a feature's or a rule's scenario list: a scenario, or +// the background the list's scenarios share. Closed, it is one line; +// open, its steps. +const ScenarioRow: FC<{ + keyword: string; + // Absent for a bare heading naming nothing. + name?: string; + description: string; + tags: string[]; + // The backgrounds whose steps run before this scenario's own: + // the feature's, then its rule's. Shown dimmed above the steps, + // so an open scenario reads whole. + backgrounds: feature_pb.Background[]; + steps: feature_pb.Step[]; + examples: feature_pb.Examples[]; + meaning: string; + links: StepLinks; + // The videos of the scenario's last run in a browser, one per + // user whose browser it drove; empty when none was recorded. + videos: feature_pb.Video[]; + // Whether the only recordings are of an earlier version of the + // scenario. + recordingsStale?: boolean; +}> = ({ + keyword, + name, + description, + tags, + backgrounds, + steps, + examples, + meaning, + links, + videos, + recordingsStale, +}) => { + const [expanded, setExpanded] = useState(false); + const [relatedKey, setRelatedKey] = useState(null); + const hues = useMemo( + () => + huesOfScenario(columnsOfExamples(examples), [ + ...backgrounds.flatMap((background) => background.steps), + ...steps, + ]), + [backgrounds, steps, examples] + ); + const related: Related = { + hues, + key: relatedKey, + onRelate: setRelatedKey, + }; + return ( +
+
setExpanded(!expanded)} + role="button" + aria-expanded={expanded} + > + {expanded ? "▾" : "▸"} + + {name} + {videos.map((video) => ( + event.stopPropagation()} + key={video.user} + > + + {videos.length > 1 ? `video · ${video.user}` : "video"} + + ))} + {recordingsStale && ( + + recording stale + + )} + {tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} +
+ {/* Rendered while the row is closed too: opening is a CSS + transition on this element, not a mount. */} +
+
+ {description !== undefined && ( + + )} +
+ {backgrounds.flatMap((background, backgroundIndex) => + background.steps.map((step, index) => ( + + )) + )} + {steps.map((step, index) => ( + + ))} +
+ {examples.map((example, index) => ( +
+
+ {example.keyword.toLowerCase()} + {example.name !== undefined && ` · ${example.name}`} +
+ {example.table !== undefined && ( + + )} +
+ ))} +
+
+
+ ); +}; + +const BackgroundRow: FC<{ + background: feature_pb.Background; + links: StepLinks; +}> = ({ background, links }) => ( + +); + +// A list's own background is listed as a row of its own and folded, +// dimmed, into each of its scenarios along with any background +// inherited from the feature. +const ScenarioRows: FC<{ + inherited: feature_pb.Background[]; + background?: feature_pb.Background; + scenarios: feature_pb.Scenario[]; + links: StepLinks; +}> = ({ inherited, background, scenarios, links }) => ( +
+ {background !== undefined && ( + + )} + {scenarios.map((scenario) => ( + + ))} +
+); + +// Both the route a link to a rule goes to and the `id` of its +// section: the feature's file, then which of its rules, counting +// from one, since a rule may have no name. +const ruleId = (filename: string, index: number): string => + `${filename}/rules/${index + 1}`; + +const RuleSection: FC<{ + rule: feature_pb.Rule; + // The rule's id on the page, a `ruleId`. + id: string; + inherited: feature_pb.Background[]; + links: StepLinks; +}> = ({ rule, id, inherited, links }) => ( +
+
+ +

{rule.name}

+ + + {countWithNoun(rule.scenarios.length, "scenario")} + +
+ {rule.description !== undefined && ( + + )} + +
+); + +// A feature on its own page. The pane's header names it and carries +// its file, counts, and description, so the card holds the +// scenarios and rules. +const FeatureCard: FC<{ + filename: string; + feature: feature_pb.Feature; + links: StepLinks; +}> = ({ filename, feature, links }) => ( +
+ {feature.error !== undefined ? ( +
{feature.error}
+ ) : ( + <> + {(feature.background !== undefined || feature.scenarios.length > 0) && ( + + )} + {feature.rules.map((rule, index) => ( + + ))} + + )} +
+); + +// One name that links to a page, on the behaviors index and in its +// sidebar. +interface NamedLink { + id: string; + name: string; +} + +// Every feature, and every rule, each linking to its page. +const namedLinksOf = ( + features: FeatureEntry[] +): { features: NamedLink[]; rules: NamedLink[] } => ({ + features: features.map(({ filename, feature }) => ({ + id: filename, + name: feature.name ?? filename, + })), + rules: features.flatMap(({ filename, feature }) => + feature.rules.map((rule, index) => ({ + id: ruleId(filename, index), + name: rule.name ?? `Rule ${index + 1}`, + })) + ), +}); + +// The sidebar's list of names linking to their pages, under a +// heading: rows of the sidebar's grid, so an eyebrow and a name cell +// each, with no count. +const NavLinks: FC<{ heading: string; links: NamedLink[] }> = ({ + heading, + links, +}) => ( + <> +
{heading}
+ {links.map((link) => ( + + {link.name} + + ))} + +); + +// The index's list of names linking to their pages, under a heading. +const LinkList: FC<{ heading: string; links: NamedLink[] }> = ({ + heading, + links, +}) => ( +
+
{heading}
+ {links.length === 0 ? ( +
None yet.
+ ) : ( + links.map((link) => ( + + {link.name} + + )) + )} +
+); + +// The behaviors page with no feature chosen: the features and the +// rules, side by side, each name linking to its page. +const FeaturesIndex: FC<{ features: FeatureEntry[] }> = ({ features }) => { + const links = namedLinksOf(features); + return ( +
+ + +
+ ); +}; + const ChangeRow: FC<{ entry: Entry; now: Date }> = ({ entry, now }) => { const row = rowOfChange(entry.change); return ( @@ -885,7 +1560,13 @@ const Overview: FC<{ // drags: the drag already moves the panel. }, [navWidth, navPanel]); - const { id: target } = useParams(); + // The behaviors page names its sections by file path, whose + // slashes a `:id` segment cannot hold, so its route matches the + // rest of the URL as a splat instead. + const params = useParams(); + const target = + params.id ?? + (params["*"] === "" || params["*"] === undefined ? undefined : params["*"]); // The dashboard's own state: what it read of the developer's API // files. Nothing here calls the developer's application, so the @@ -937,6 +1618,57 @@ const Overview: FC<{ const linkedDataTypes = useMemo(() => linkDataTypes({ apis }), [apis]); + // What the developer's `.feature` files describe. + const features: Features = useMemo( + () => response?.features ?? {}, + [response?.features] + ); + + const featureEntries = useMemo(() => sortedFeatures(features), [features]); + + // The feature the URL names, by its file or by one of its rules + // (`ruleId`); `undefined` for the page with no feature chosen, + // which lists them all. + const chosenFeature = useMemo( + () => + target === undefined + ? undefined + : featureEntries.find( + ({ filename }) => + target === filename || target.startsWith(`${filename}/rules/`) + ), + [featureEntries, target] + ); + + const scenarioCount = useMemo( + () => + featureEntries.reduce( + (total, entry) => total + scenariosOfFeature(entry.feature).length, + 0 + ), + [featureEntries] + ); + + // Rules are what the page counts by once a project writes them; + // until then, scenarios. + const ruleCount = useMemo( + () => + featureEntries.reduce( + (total, entry) => total + entry.feature.rules.length, + 0 + ), + [featureEntries] + ); + const behaviorsCount = + ruleCount > 0 + ? countWithNoun(ruleCount, "rule") + : countWithNoun(scenarioCount, "scenario"); + + // Where the backticked spans of steps link, derived from the same + // APIs the state page shows, so a link can never point at a state + // type the page does not have. + const links = useMemo(() => stepLinks(apis), [apis]); + // A referrer is either a state type or a data type, and its link // must open the page that lists it. const pageOfTypeId = useMemo(() => { @@ -949,7 +1681,9 @@ const Overview: FC<{ }, [apis]); // The changelog is one list rather than a set of packages, and - // the graph is one canvas, so the sidebar has nothing to index. + // the graph is one canvas, so the sidebar has nothing to index; + // the behaviors page indexes its features and rules as two flat + // lists of its own, below. const entries: NavEntry[] = useMemo( () => page === "changelog" || page === "graph" @@ -963,17 +1697,25 @@ const Overview: FC<{ count: countWithNoun(stateType.methods.length, "method"), })) ) + : page === "behaviors" + ? [] : linkedDataTypes.map((linkedDataType) => ({ id: linkedDataType.id, name: linkedDataType.name, package: linkedDataType.package, count: countWithNoun(linkedDataType.properties.length, "property"), })), - [page, apis, linkedDataTypes] + [page, apis, featureEntries, linkedDataTypes] ); const packages = useMemo(() => groupByPackage(entries), [entries]); + // The behaviors sidebar's two lists. + const behaviorLinks = useMemo( + () => namedLinksOf(featureEntries), + [featureEntries] + ); + // The changelog, read here rather than in its page so that the // nav's count is right before the page is ever opened. const { useReverseRange } = useOrderedMap({ id: CHANGELOG_ID }); @@ -1001,7 +1743,14 @@ const Overview: FC<{ [graphStateTypes] ); - const eyebrow = page === "changelog" ? "history" : "application domain"; + const eyebrow = + page === "changelog" + ? "history" + : page === "behaviors" + ? chosenFeature === undefined + ? "application behavior" + : "feature" + : "application domain"; const heading = page === "changelog" @@ -1016,6 +1765,13 @@ const Overview: FC<{ packages.length, "package" )}` + : page === "behaviors" + ? chosenFeature === undefined + ? `${behaviorsCount} in ${countWithNoun( + featureEntries.length, + "feature" + )}` + : chosenFeature.feature.name ?? chosenFeature.filename : `${countWithNoun( linkedDataTypes.length, "data type" @@ -1043,7 +1799,7 @@ const Overview: FC<{ element.scrollIntoView(); scrolledToTarget = true; } - }, [navigationType, page, target, apis, linkedDataTypes]); + }, [navigationType, page, target, apis, linkedDataTypes, featureEntries]); const navigate = useNavigate(); @@ -1117,6 +1873,7 @@ const Overview: FC<{ const counts: Record = { state: stateTypeCount, data: linkedDataTypes.length, + behaviors: ruleCount > 0 ? ruleCount : scenarioCount, changelog: shownChangelog.length, graph: calls, }; @@ -1144,6 +1901,12 @@ const Overview: FC<{