From 7b16bf944060564cfb8fe7e3ba11fbb1b75d737e Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 21 Nov 2023 21:25:44 +0000 Subject: [PATCH 1/4] hivesim: Add docs generation --- go.mod | 1 + go.sum | 2 + hivesim/docs.go | 375 ++++++++++++++++++++++++++++++++++++++ hivesim/docs_test.go | 95 ++++++++++ hivesim/hive.go | 97 ++++++++-- hivesim/hive_test.go | 21 ++- hivesim/testapi.go | 95 +++++++--- internal/simapi/simapi.go | 3 + 8 files changed, 637 insertions(+), 52 deletions(-) create mode 100644 hivesim/docs.go create mode 100644 hivesim/docs_test.go diff --git a/go.mod b/go.mod index cc020945ae..698c23bffd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/evanw/esbuild v0.18.11 github.com/fsouza/go-dockerclient v1.9.8 github.com/gorilla/mux v1.8.0 + github.com/lithammer/dedent v1.1.0 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 golang.org/x/net v0.17.0 gopkg.in/inconshreveable/log15.v2 v2.0.0-20200109203555-b30bc20e4fd1 diff --git a/go.sum b/go.sum index dde2612f42..d5f87c3ca2 100644 --- a/go.sum +++ b/go.sum @@ -226,6 +226,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= diff --git a/hivesim/docs.go b/hivesim/docs.go new file mode 100644 index 0000000000..0b0c4f64fe --- /dev/null +++ b/hivesim/docs.go @@ -0,0 +1,375 @@ +package hivesim + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/ethereum/hive/internal/simapi" + "github.com/lithammer/dedent" + "gopkg.in/inconshreveable/log15.v2" +) + +func toMarkdownLink(title string) string { + removeChars := []string{":", "#", "'", "\"", "`", "*", "+", ",", ";"} + title = strings.ReplaceAll(strings.ToLower(title), " ", "-") + for _, invalidChar := range removeChars { + title = strings.ReplaceAll(title, invalidChar, "") + } + return title +} + +func formatDescription(desc string) string { + desc = dedent.Dedent(desc) + // Replace single quotes with backticks, since backticks are used for markdown code blocks and + // they cannot be escaped in golang (when used within backticks string). + desc = strings.ReplaceAll(desc, "'", "`") + return desc +} + +type markdownTestCase simapi.TestRequest + +func (tc *markdownTestCase) commandLine(simName string, suiteName string) string { + return fmt.Sprintf("./hive --client --sim %s --sim.limit \"%s/%s\"", simName, suiteName, tc.Name) +} + +func (tc *markdownTestCase) toBePrinted() bool { + return tc.Description != "" +} + +func (tc *markdownTestCase) displayName() string { + if tc.DisplayName != "" { + return tc.DisplayName + } + return tc.Name +} + +func (tc *markdownTestCase) description() string { + return formatDescription(tc.Description) +} + +func (tc *markdownTestCase) toMarkdown(simName string, suiteName string, depth int) string { + sb := strings.Builder{} + + // Print test case display name + sb.WriteString(fmt.Sprintf("%s %s\n\n", strings.Repeat("#", depth), tc.displayName())) + + // Print command-line to run + sb.WriteString(fmt.Sprintf("%s Run\n\n", strings.Repeat("#", depth+1))) + sb.WriteString("
\n") + sb.WriteString("Command-line\n\n") + sb.WriteString(fmt.Sprintf("```bash\n%s\n```\n\n", tc.commandLine(simName, suiteName))) + sb.WriteString("
\n\n") + + // Print description + sb.WriteString(fmt.Sprintf("%s Description\n\n", strings.Repeat("#", depth+1))) + sb.WriteString(fmt.Sprintf("%s\n\n", tc.description())) + + return sb.String() +} + +func getCategories(testCases map[TestID]*markdownTestCase) []string { + categoryMap := make(map[string]struct{}) + categories := make([]string, 0) + for _, tc := range testCases { + if tc.toBePrinted() { + if _, ok := categoryMap[tc.Category]; !ok { + categories = append(categories, tc.Category) + categoryMap[tc.Category] = struct{}{} + } + } + } + return categories +} + +type markdownSuite struct { + simapi.TestRequest + running bool + tests map[TestID]*markdownTestCase +} + +func (s *markdownSuite) displayName() string { + if s.DisplayName != "" { + return s.DisplayName + } + return fmt.Sprintf("`%s`", s.Name) +} + +func (s *markdownSuite) mardownFilePath() string { + if s.Location != "" { + return fmt.Sprintf("%s/TESTS.md", s.Location) + } + title := strings.ReplaceAll(strings.ToUpper(s.Name), " ", "-") + return fmt.Sprintf("TESTS-%s.md", title) +} + +func (s *markdownSuite) commandLine(simName string) string { + return fmt.Sprintf("./hive --client --sim %s --sim.limit \"%s/\"", simName, s.Name) +} + +func (s *markdownSuite) description() string { + return formatDescription(s.Description) +} + +func (s *markdownSuite) toMarkdown(simName string) (string, error) { + headerBuilder := strings.Builder{} + categoryBuilder := map[string]*strings.Builder{} + headerBuilder.WriteString(fmt.Sprintf("# %s - Test Cases\n\n", s.displayName())) + + headerBuilder.WriteString(fmt.Sprintf("%s\n\n", s.description())) + + headerBuilder.WriteString("## Run Suite\n\n") + + headerBuilder.WriteString("
\n") + headerBuilder.WriteString("Command-line\n\n") + headerBuilder.WriteString(fmt.Sprintf("```bash\n%s\n```\n\n", s.commandLine(simName))) + headerBuilder.WriteString("
\n\n") + + categories := getCategories(s.tests) + + tcDepth := 3 + if len(categories) > 1 { + headerBuilder.WriteString("## Test Case Categories\n\n") + for _, category := range categories { + if category == "" { + category = "Other" + } + headerBuilder.WriteString(fmt.Sprintf("- [%s](#category-%s)\n\n", category, toMarkdownLink(category))) + } + } else { + headerBuilder.WriteString("## Test Cases\n\n") + } + + for _, tc := range s.tests { + if !tc.toBePrinted() { + continue + } + contentBuilder, ok := categoryBuilder[tc.Category] + if !ok { + contentBuilder = &strings.Builder{} + categoryBuilder[tc.Category] = contentBuilder + } + contentBuilder.WriteString(tc.toMarkdown(simName, s.Name, tcDepth)) + } + + if len(categoryBuilder) > 1 { + for _, category := range categories { + if category == "" { + category = "Other" + } + contentBuilder, ok := categoryBuilder[category] + if !ok { + continue + } + headerBuilder.WriteString(fmt.Sprintf("## Category: %s\n\n", category)) + headerBuilder.WriteString(contentBuilder.String()) + } + } else { + for _, contentBuilder := range categoryBuilder { + headerBuilder.WriteString(contentBuilder.String()) + } + } + + return headerBuilder.String(), nil +} + +func (s *markdownSuite) toMarkdownFile(fw FileWriter, simName string) error { + // Create the file. + file, err := fw.CreateWriter(s.mardownFilePath()) + if err != nil { + return err + } + defer file.Close() + + // Write the markdown. + markdown, err := s.toMarkdown(simName) + if err != nil { + return err + } + _, err = file.Write([]byte(markdown)) + return err +} + +// Docs collector object +type docsCollector struct { + simName string + outputDir string + suites map[SuiteID]*markdownSuite +} + +func simulatorNameFromBinaryPath() string { + execPath, err := os.Executable() + if err != nil { + panic(err) + } + var ( + path = filepath.Dir(execPath) + name = filepath.Base(path) + names = make([]string, 0) + ) + for path != "/" && name != "simulators" { + names = append([]string{name}, names...) + path = filepath.Dir(path) + name = filepath.Base(path) + } + return strings.Join(names, "/") +} + +// NewDocsCollector creates a new docs collector object. +func NewDocsCollector() *docsCollector { + docs := &docsCollector{ + suites: make(map[SuiteID]*markdownSuite), + } + // Set the simulation name: if `HIVE_SIMULATOR_NAME` is set, use that, otherwise use the + // folder name and its parent. + docs.simName = os.Getenv("HIVE_SIMULATOR_NAME") + if docs.simName == "" { + docs.simName = simulatorNameFromBinaryPath() + } + + // Set the output directory: if `HIVE_DOCS_OUTPUT_DIR` is set, use that, otherwise use the + // current directory. + docs.outputDir = os.Getenv("HIVE_DOCS_OUTPUT_DIR") + if docs.outputDir == "" { + var err error + docs.outputDir, err = os.Getwd() + if err != nil { + panic(err) + } + } + + return docs +} + +// Returns true if any suite is still running +func (docs *docsCollector) AnyRunning() bool { + for _, s := range docs.suites { + if s.running { + return true + } + } + return false +} + +func (docs *docsCollector) StartSuite(suite *simapi.TestRequest, simlog string) (SuiteID, error) { + // Create a new markdown suite. + markdownSuite := &markdownSuite{ + TestRequest: *suite, + running: true, + tests: make(map[TestID]*markdownTestCase), + } + // Next suite id + suiteID := SuiteID(len(docs.suites)) + // Add the suite to the map. + docs.suites[suiteID] = markdownSuite + // Return the suite ID. + return suiteID, nil +} + +func (docs *docsCollector) EndSuite(testSuite SuiteID) error { + suite, ok := docs.suites[testSuite] + if !ok { + return fmt.Errorf("test suite %d does not exist", testSuite) + } + suite.running = false + if !docs.AnyRunning() { + // Generate markdown files when all suites are done. + if err := docs.genSimulatorMarkdownFiles(NewFileWriter(docs.outputDir)); err != nil { + log15.Error("can't generate markdown files", "err", err) + } + } + return nil +} + +func (docs *docsCollector) StartTest(testSuite SuiteID, test *simapi.TestRequest) (TestID, error) { + // Create a new markdown test case. + markdownTest := markdownTestCase(*test) + // Check if suite exists. + if _, ok := docs.suites[testSuite]; !ok { + return 0, fmt.Errorf("test suite %d does not exist", testSuite) + } + // Next test id + testID := TestID(len(docs.suites[testSuite].tests)) + // Add the test to the map. + docs.suites[testSuite].tests[testID] = &markdownTest + // Return the test ID. + return testID, nil +} + +func (docs *docsCollector) EndTest(testSuite SuiteID, test TestID, testResult TestResult) error { + // No-op in docs mode. + return nil +} + +func (docs *docsCollector) ClientTypes() ([]*ClientDefinition, error) { + // Return a dummy "docs" client type. + return []*ClientDefinition{ + { + Name: "Client", + Version: "1.0.0", + }, + }, nil +} + +func (docs *docsCollector) generateIndex(simName string) (string, error) { + headerBuilder := strings.Builder{} + headerBuilder.WriteString(fmt.Sprintf("# Simulator `%s` Test Cases\n\n", simName)) + headerBuilder.WriteString("## Test Suites\n\n") + for _, s := range docs.suites { + headerBuilder.WriteString(fmt.Sprintf("### - [%s](%s)\n", s.displayName(), s.mardownFilePath())) + headerBuilder.WriteString(s.Description) + headerBuilder.WriteString("\n\n") + } + return headerBuilder.String() + "\n", nil +} + +func (docs *docsCollector) generateIndexFile(fw FileWriter, simName string) error { + markdownIndex, err := docs.generateIndex(simName) + if err != nil { + return err + } + file, err := fw.CreateWriter("TESTS.md") + if err != nil { + return err + } + defer file.Close() + _, err = file.Write([]byte(markdownIndex)) + return err +} + +type FileWriter interface { + CreateWriter(path string) (io.WriteCloser, error) +} + +type fileWriter struct { + path string +} + +var _ FileWriter = (*fileWriter)(nil) + +func (fw *fileWriter) CreateWriter(path string) (io.WriteCloser, error) { + filePath := filepath.FromSlash(fmt.Sprintf("%s/%s", fw.path, path)) + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return nil, err + } + return os.Create(filePath) +} + +func NewFileWriter(path string) FileWriter { + return &fileWriter{path} +} + +func (docs *docsCollector) genSimulatorMarkdownFiles(fw FileWriter) error { + err := docs.generateIndexFile(fw, docs.simName) + if err != nil { + return err + } + for _, s := range docs.suites { + if err := s.toMarkdownFile(fw, docs.simName); err != nil { + return err + } + } + return nil +} diff --git a/hivesim/docs_test.go b/hivesim/docs_test.go new file mode 100644 index 0000000000..3c96d046bc --- /dev/null +++ b/hivesim/docs_test.go @@ -0,0 +1,95 @@ +package hivesim + +import ( + "bytes" + "io" + "testing" + + "github.com/ethereum/hive/internal/simapi" +) + +type nopCloser struct { + io.Writer +} + +func (c nopCloser) Close() error { + // No-op + return nil +} + +type testWriter struct { + fileMap map[string]*bytes.Buffer +} + +func (tw *testWriter) Contains(file string) bool { + for k := range tw.fileMap { + if k == file { + return true + } + } + return false +} + +func (tw *testWriter) CreateWriter(path string) (io.WriteCloser, error) { + if _, ok := tw.fileMap[path]; !ok { + tw.fileMap[path] = &bytes.Buffer{} + } + return nopCloser{tw.fileMap[path]}, nil +} + +func TestSuiteDocsGen(t *testing.T) { + suiteMap := map[SuiteID]*markdownSuite{ + 1: { + TestRequest: simapi.TestRequest{ + Name: "suite1", + Description: `This is a description of suite1.`, + }, + tests: map[TestID]*markdownTestCase{ + 1: { + Name: "test1", + Description: `This is a description of test1 in suite1.`, + }, + 2: { + Name: "test2", + Description: `This is a description of test2 in suite1.`, + }, + }, + }, + 2: { + TestRequest: simapi.TestRequest{ + Name: "suite2", + DisplayName: "Suite 2", + Location: "suite2", + Description: `This is a description of suite2.`, + }, + tests: map[TestID]*markdownTestCase{ + 1: { + Name: "testA", + Description: `This is a description of testA in suite2.`, + }, + 2: { + Name: "testB", + DisplayName: "Test B", + Description: `This is a description of testB in suite2.`, + }, + }, + }, + } + docsSim := &docsCollector{ + suites: suiteMap, + simName: "sim", + } + tw := &testWriter{map[string]*bytes.Buffer{}} + if err := docsSim.genSimulatorMarkdownFiles(tw); err != nil { + t.Fatal(err) + } + if len(tw.fileMap) != 3 { + t.Fatalf("expected 3 files, got %d", len(tw.fileMap)) + } + + for _, expFile := range []string{"TESTS.md", "TESTS-SUITE1.md", "suite2/TESTS.md"} { + if !tw.Contains(expFile) { + t.Fatalf("expected file %s not found", expFile) + } + } +} diff --git a/hivesim/hive.go b/hivesim/hive.go index ba29d5d2f1..1837fec34d 100644 --- a/hivesim/hive.go +++ b/hivesim/hive.go @@ -21,22 +21,32 @@ import ( // Simulation wraps the simulation HTTP API provided by hive. type Simulation struct { - url string - m testMatcher - ll int + url string + m testMatcher + docs *docsCollector + ll int } // New looks up the hive host URI using the HIVE_SIMULATOR environment variable // and connects to it. It will panic if HIVE_SIMULATOR is not set. +// If HIVE_DOCS_MODE is set to "true", it will inhibit most of the functionality +// in order to simplify execution for documentation generation. func New() *Simulation { - url, isSet := os.LookupEnv("HIVE_SIMULATOR") - if !isSet { - panic("HIVE_SIMULATOR environment variable not set") - } - if url == "" { - panic("HIVE_SIMULATOR environment variable is empty") + var ( + docs *docsCollector + url string + ) + if cc := os.Getenv("HIVE_DOCS_MODE"); cc == "true" { + docs = NewDocsCollector() + } else { + var isSet bool + if url, isSet = os.LookupEnv("HIVE_SIMULATOR"); !isSet { + panic("HIVE_SIMULATOR environment variable not set") + } else if url == "" { + panic("HIVE_SIMULATOR environment variable is empty") + } } - sim := &Simulation{url: url} + sim := &Simulation{url: url, docs: docs} if p := os.Getenv("HIVE_TEST_PATTERN"); p != "" { m, err := parseTestPattern(p) if err != nil { @@ -80,18 +90,29 @@ func (sim *Simulation) TestPattern() (suiteExpr string, testNameExpr string) { return se, te } +// CollectTestsOnly returns true if the simulation is running in collect-tests-only mode. +func (sim *Simulation) CollectTestsOnly() bool { + return sim.docs != nil +} + // EndTest finishes the test case, cleaning up everything, logging results, and returning // an error if the process could not be completed. func (sim *Simulation) EndTest(testSuite SuiteID, test TestID, testResult TestResult) error { + if sim.docs != nil { + return sim.docs.EndTest(testSuite, test, testResult) + } url := fmt.Sprintf("%s/testsuite/%d/test/%d", sim.url, testSuite, test) return post(url, &testResult, nil) } // StartSuite signals the start of a test suite. -func (sim *Simulation) StartSuite(name, description, simlog string) (SuiteID, error) { +func (sim *Simulation) StartSuite(suite *simapi.TestRequest, simlog string) (SuiteID, error) { + if sim.docs != nil { + return sim.docs.StartSuite(suite, simlog) + } var ( url = fmt.Sprintf("%s/testsuite", sim.url) - req = &simapi.TestRequest{Name: name, Description: description} + req = suite resp SuiteID ) err := post(url, req, &resp) @@ -100,15 +121,21 @@ func (sim *Simulation) StartSuite(name, description, simlog string) (SuiteID, er // EndSuite signals the end of a test suite. func (sim *Simulation) EndSuite(testSuite SuiteID) error { + if sim.docs != nil { + return sim.docs.EndSuite(testSuite) + } url := fmt.Sprintf("%s/testsuite/%d", sim.url, testSuite) return requestDelete(url) } // StartTest starts a new test case, returning the testcase id as a context identifier. -func (sim *Simulation) StartTest(testSuite SuiteID, name string, description string) (TestID, error) { +func (sim *Simulation) StartTest(testSuite SuiteID, test *simapi.TestRequest) (TestID, error) { + if sim.docs != nil { + return sim.docs.StartTest(testSuite, test) + } var ( url = fmt.Sprintf("%s/testsuite/%d/test", sim.url, testSuite) - req = &simapi.TestRequest{Name: name, Description: description} + req = test resp TestID ) err := post(url, req, &resp) @@ -118,6 +145,9 @@ func (sim *Simulation) StartTest(testSuite SuiteID, name string, description str // ClientTypes returns all client types available to this simulator run. This depends on // both the available client set and the command line filters. func (sim *Simulation) ClientTypes() ([]*ClientDefinition, error) { + if sim.docs != nil { + return sim.docs.ClientTypes() + } var ( url = fmt.Sprintf("%s/clients", sim.url) resp []*ClientDefinition @@ -131,6 +161,9 @@ func (sim *Simulation) ClientTypes() ([]*ClientDefinition, error) { // GetClientTypes. The input is used as environment variables in the new container. // Returns container id and ip. func (sim *Simulation) StartClient(testSuite SuiteID, test TestID, parameters map[string]string, initFiles map[string]string) (string, net.IP, error) { + if sim.docs != nil { + return "", nil, errors.New("StartClient is not supported in docs mode") + } clientType, ok := parameters["CLIENT"] if !ok { return "", nil, errors.New("missing 'CLIENT' parameter") @@ -141,6 +174,9 @@ func (sim *Simulation) StartClient(testSuite SuiteID, test TestID, parameters ma // StartClientWithOptions starts a new node (or other container) with specified options. // Returns container id and ip. func (sim *Simulation) StartClientWithOptions(testSuite SuiteID, test TestID, clientType string, options ...StartOption) (string, net.IP, error) { + if sim.docs != nil { + return "", nil, errors.New("StartClientWithOptions is not supported in docs mode") + } var ( url = fmt.Sprintf("%s/testsuite/%d/test/%d/node", sim.url, testSuite, test) resp simapi.StartNodeResponse @@ -170,6 +206,9 @@ func (sim *Simulation) StartClientWithOptions(testSuite SuiteID, test TestID, cl // StopClient signals to the host that the node is no longer required. func (sim *Simulation) StopClient(testSuite SuiteID, test TestID, nodeid string) error { + if sim.docs != nil { + return errors.New("StopClient is not supported in docs mode") + } req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/testsuite/%d/test/%d/node/%s", sim.url, testSuite, test, nodeid), nil) if err != nil { return err @@ -180,6 +219,9 @@ func (sim *Simulation) StopClient(testSuite SuiteID, test TestID, nodeid string) // PauseClient signals to the host that the node needs to be paused. func (sim *Simulation) PauseClient(testSuite SuiteID, test TestID, nodeid string) error { + if sim.docs != nil { + return errors.New("PauseClient is not supported in docs mode") + } req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/testsuite/%d/test/%d/node/%s/pause", sim.url, testSuite, test, nodeid), nil) if err != nil { return err @@ -190,6 +232,9 @@ func (sim *Simulation) PauseClient(testSuite SuiteID, test TestID, nodeid string // UnpauseClient signals to the host that the node needs to be unpaused. func (sim *Simulation) UnpauseClient(testSuite SuiteID, test TestID, nodeid string) error { + if sim.docs != nil { + return errors.New("UnpauseClient is not supported in docs mode") + } req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/testsuite/%d/test/%d/node/%s/pause", sim.url, testSuite, test, nodeid), nil) if err != nil { return err @@ -200,11 +245,17 @@ func (sim *Simulation) UnpauseClient(testSuite SuiteID, test TestID, nodeid stri // ClientEnodeURL returns the enode URL of a running client. func (sim *Simulation) ClientEnodeURL(testSuite SuiteID, test TestID, node string) (string, error) { + if sim.docs != nil { + return "", errors.New("ClientEnodeURL is not supported in docs mode") + } return sim.ClientEnodeURLNetwork(testSuite, test, node, "bridge") } // ClientEnodeURLCustomNetwork returns the enode URL of a running client in a custom network. func (sim *Simulation) ClientEnodeURLNetwork(testSuite SuiteID, test TestID, node string, network string) (string, error) { + if sim.docs != nil { + return "", errors.New("ClientEnodeURLNetwork is not supported in docs mode") + } resp, err := sim.ClientExec(testSuite, test, node, []string{"enode.sh"}) if err != nil { return "", err @@ -243,6 +294,9 @@ func (sim *Simulation) ClientEnodeURLNetwork(testSuite SuiteID, test TestID, nod // ClientExec runs a command in a running client. func (sim *Simulation) ClientExec(testSuite SuiteID, test TestID, nodeid string, cmd []string) (*ExecInfo, error) { + if sim.docs != nil { + return nil, errors.New("ClientExec is not supported in docs mode") + } var ( url = fmt.Sprintf("%s/testsuite/%d/test/%d/node/%s/exec", sim.url, testSuite, test, nodeid) req = &simapi.ExecRequest{Command: cmd} @@ -255,12 +309,18 @@ func (sim *Simulation) ClientExec(testSuite SuiteID, test TestID, nodeid string, // CreateNetwork sends a request to the hive server to create a docker network by // the given name. func (sim *Simulation) CreateNetwork(testSuite SuiteID, networkName string) error { + if sim.docs != nil { + return errors.New("CreateNetwork is not supported in docs mode") + } url := fmt.Sprintf("%s/testsuite/%d/network/%s", sim.url, testSuite, networkName) return post(url, nil, nil) } // RemoveNetwork sends a request to the hive server to remove the given network. func (sim *Simulation) RemoveNetwork(testSuite SuiteID, network string) error { + if sim.docs != nil { + return errors.New("RemoveNetwork is not supported in docs mode") + } url := fmt.Sprintf("%s/testsuite/%d/network/%s", sim.url, testSuite, network) return requestDelete(url) } @@ -268,6 +328,9 @@ func (sim *Simulation) RemoveNetwork(testSuite SuiteID, network string) error { // ConnectContainer sends a request to the hive server to connect the given // container to the given network. func (sim *Simulation) ConnectContainer(testSuite SuiteID, network, containerID string) error { + if sim.docs != nil { + return errors.New("ConnectContainer is not supported in docs mode") + } url := fmt.Sprintf("%s/testsuite/%d/network/%s/%s", sim.url, testSuite, network, containerID) return post(url, nil, nil) } @@ -275,6 +338,9 @@ func (sim *Simulation) ConnectContainer(testSuite SuiteID, network, containerID // DisconnectContainer sends a request to the hive server to disconnect the given // container from the given network. func (sim *Simulation) DisconnectContainer(testSuite SuiteID, network, containerID string) error { + if sim.docs != nil { + return errors.New("DisconnectContainer is not supported in docs mode") + } url := fmt.Sprintf("%s/testsuite/%d/network/%s/%s", sim.url, testSuite, network, containerID) return requestDelete(url) } @@ -282,6 +348,9 @@ func (sim *Simulation) DisconnectContainer(testSuite SuiteID, network, container // ContainerNetworkIP returns the IP address of a container on the given network. If the // container ID is "simulation", it returns the IP address of the simulator container. func (sim *Simulation) ContainerNetworkIP(testSuite SuiteID, network, containerID string) (string, error) { + if sim.docs != nil { + return "", errors.New("ContainerNetworkIP is not supported in docs mode") + } var ( url = fmt.Sprintf("%s/testsuite/%d/network/%s/%s", sim.url, testSuite, network, containerID) resp string diff --git a/hivesim/hive_test.go b/hivesim/hive_test.go index 64c5053cdb..8c0016bcf1 100644 --- a/hivesim/hive_test.go +++ b/hivesim/hive_test.go @@ -13,6 +13,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/ethereum/hive/internal/fakes" "github.com/ethereum/hive/internal/libhive" + "github.com/ethereum/hive/internal/simapi" ) // This test checks that the API returns configured client names correctly. @@ -73,11 +74,11 @@ func TestEnodeReplaceIP(t *testing.T) { // Start the client. sim := NewAt(srv.URL) - suiteID, err := sim.StartSuite("suite", "", "") + suiteID, err := sim.StartSuite(&simapi.TestRequest{Name: "suite"}, "") if err != nil { t.Fatal("can't start suite:", err) } - testID, err := sim.StartTest(suiteID, "test", "") + testID, err := sim.StartTest(suiteID, &simapi.TestRequest{Name: "test"}) if err != nil { t.Fatal("can't start test:", err) } @@ -125,11 +126,11 @@ func TestStartClientStartOptions(t *testing.T) { // Start the suite and test. sim := NewAt(srv.URL) - suiteID, err := sim.StartSuite("suite", "", "") + suiteID, err := sim.StartSuite(&simapi.TestRequest{Name: "suite"}, "") if err != nil { t.Fatal("can't start suite:", err) } - testID, err := sim.StartTest(suiteID, "test", "") + testID, err := sim.StartTest(suiteID, &simapi.TestRequest{Name: "test"}) if err != nil { t.Fatal("can't start test:", err) } @@ -262,11 +263,11 @@ func TestRunProgram(t *testing.T) { defer tm.Terminate() sim := NewAt(srv.URL) - suiteID, err := sim.StartSuite("suite", "", "") + suiteID, err := sim.StartSuite(&simapi.TestRequest{Name: "suite"}, "") if err != nil { t.Fatal("can't start suite:", err) } - testID, err := sim.StartTest(suiteID, "test", "") + testID, err := sim.StartTest(suiteID, &simapi.TestRequest{Name: "test"}) if err != nil { t.Fatal("can't start test:", err) } @@ -309,11 +310,11 @@ func TestStartClientErrors(t *testing.T) { defer tm.Terminate() sim := NewAt(srv.URL) - suiteID, err := sim.StartSuite("suite", "", "") + suiteID, err := sim.StartSuite(&simapi.TestRequest{Name: "suite"}, "") if err != nil { t.Fatal("can't start suite:", err) } - testID, err := sim.StartTest(suiteID, "test", "") + testID, err := sim.StartTest(suiteID, &simapi.TestRequest{Name: "test"}) if err != nil { t.Fatal("can't start test:", err) } @@ -365,11 +366,11 @@ func TestStartClientInitialNetworks(t *testing.T) { defer tm.Terminate() sim := NewAt(srv.URL) - suiteID, err := sim.StartSuite("suite", "", "") + suiteID, err := sim.StartSuite(&simapi.TestRequest{Name: "suite"}, "") if err != nil { t.Fatal("can't start suite:", err) } - testID, err := sim.StartTest(suiteID, "test", "") + testID, err := sim.StartTest(suiteID, &simapi.TestRequest{Name: "test"}) if err != nil { t.Fatal("can't start test:", err) } diff --git a/hivesim/testapi.go b/hivesim/testapi.go index 4b4f052a63..27f97ad2c3 100644 --- a/hivesim/testapi.go +++ b/hivesim/testapi.go @@ -9,15 +9,29 @@ import ( "sync" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethereum/hive/internal/simapi" ) // Suite is the description of a test suite. type Suite struct { - Name string - Description string + Name string // Name is the unique identifier for the suite [Mandatory] + DisplayName string // Display name for the suite (Name will be used if unset) [Optional] + Location string // Documentation output location for the test suite [Optional] + Category string // Category of the test suite [Optional] + Description string // Description of the test suite (if empty, suite won't appear in documentation) [Optional] Tests []AnyTest } +func (s *Suite) request() *simapi.TestRequest { + return &simapi.TestRequest{ + Name: s.Name, + DisplayName: s.DisplayName, + Location: s.Location, + Category: s.Category, + Description: s.Description, + } +} + // Add adds a test to the suite. func (s *Suite) Add(test AnyTest) *Suite { s.Tests = append(s.Tests, test) @@ -56,7 +70,7 @@ func RunSuite(host *Simulation, suite Suite) error { return nil } - suiteID, err := host.StartSuite(suite.Name, suite.Description, "") + suiteID, err := host.StartSuite(suite.request(), "") if err != nil { return err } @@ -93,8 +107,10 @@ func MustRunSuite(host *Simulation, suite Suite) { type TestSpec struct { // These fields are displayed in the UI. Be sure to add // a meaningful description here. - Name string - Description string + Name string // Name is the unique identifier for the test [Mandatory] + DisplayName string // Display name for the test (Name will be used if unset) [Optional] + Description string // Description of the test (if empty, test won't appear in documentation) [Optional] + Category string // Category of the test [Optional] // If AlwaysRun is true, the test will run even if Name does not match the test // pattern. This option is useful for tests that launch a client instance and @@ -115,8 +131,10 @@ type TestSpec struct { type ClientTestSpec struct { // These fields are displayed in the UI. Be sure to add // a meaningful description here. - Name string - Description string + Name string // Name is the unique identifier for the test [Mandatory] + DisplayName string // Display name for the test (Name will be used if unset) [Optional] + Description string // Description of the test (if empty, test won't appear in documentation) [Optional] + Category string // Category of the test [Optional] // If AlwaysRun is true, the test will run even if Name does not match the test // pattern. This option is useful for tests that launch a client instance and @@ -208,11 +226,13 @@ func (t *T) StartClient(clientType string, option ...StartOption) *Client { // It waits for the subtest to complete. func (t *T) RunClient(clientType string, spec ClientTestSpec) { test := testSpec{ - suiteID: t.SuiteID, - suite: t.suite, - name: clientTestName(spec.Name, clientType), - desc: spec.Description, - alwaysRun: spec.AlwaysRun, + suiteID: t.SuiteID, + suite: t.suite, + name: clientTestName(spec.Name, clientType), + displayName: spec.DisplayName, + category: spec.Category, + desc: spec.Description, + alwaysRun: spec.AlwaysRun, } runTest(t.Sim, test, func(t *T) { client := t.StartClient(clientType, spec.Parameters, WithStaticFiles(spec.Files)) @@ -298,11 +318,22 @@ func (t *T) FailNow() { } type testSpec struct { - suiteID SuiteID - suite *Suite - name string - desc string - alwaysRun bool + suiteID SuiteID + suite *Suite + name string + displayName string + category string + desc string + alwaysRun bool +} + +func (spec testSpec) request() *simapi.TestRequest { + return &simapi.TestRequest{ + Name: spec.name, + DisplayName: spec.displayName, + Category: spec.category, + Description: spec.desc, + } } func runTest(host *Simulation, test testSpec, runit func(t *T)) error { @@ -319,7 +350,7 @@ func runTest(host *Simulation, test testSpec, runit func(t *T)) error { SuiteID: test.suiteID, suite: test.suite, } - testID, err := host.StartTest(test.suiteID, test.name, test.desc) + testID, err := host.StartTest(test.suiteID, test.request()) if err != nil { return err } @@ -343,6 +374,10 @@ func runTest(host *Simulation, test testSpec, runit func(t *T)) error { } close(done) }() + if host.CollectTestsOnly() && !test.alwaysRun { + // Don't run the test if we're just generating docs. + return + } runit(t) }() <-done @@ -361,11 +396,13 @@ func (spec ClientTestSpec) runTest(host *Simulation, suiteID SuiteID, suite *Sui continue } test := testSpec{ - suiteID: suiteID, - suite: suite, - name: clientTestName(spec.Name, clientDef.Name), - desc: spec.Description, - alwaysRun: spec.AlwaysRun, + suiteID: suiteID, + suite: suite, + name: clientTestName(spec.Name, clientDef.Name), + displayName: spec.DisplayName, + category: spec.Category, + desc: spec.Description, + alwaysRun: spec.AlwaysRun, } err := runTest(host, test, func(t *T) { client := t.StartClient(clientDef.Name, spec.Parameters, WithStaticFiles(spec.Files)) @@ -391,11 +428,13 @@ func clientTestName(name, clientType string) string { func (spec TestSpec) runTest(host *Simulation, suiteID SuiteID, suite *Suite) error { test := testSpec{ - suiteID: suiteID, - suite: suite, - name: spec.Name, - desc: spec.Description, - alwaysRun: spec.AlwaysRun, + suiteID: suiteID, + suite: suite, + name: spec.Name, + displayName: spec.DisplayName, + category: spec.Category, + desc: spec.Description, + alwaysRun: spec.AlwaysRun, } return runTest(host, test, spec.Run) } diff --git a/internal/simapi/simapi.go b/internal/simapi/simapi.go index 488fbd1a22..af189d0510 100644 --- a/internal/simapi/simapi.go +++ b/internal/simapi/simapi.go @@ -3,6 +3,9 @@ package simapi type TestRequest struct { Name string `json:"name"` + DisplayName string `json:"display_name"` + Location string `json:"location"` + Category string `json:"category"` Description string `json:"description"` } From 71586adf871ba4c79d2774b77dc79fd444b38ebb Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 21 Nov 2023 21:59:39 +0000 Subject: [PATCH 2/4] docs: Add documentation generator info --- docs/simulators.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/simulators.md b/docs/simulators.md index f38ff84e89..95b7fc8004 100644 --- a/docs/simulators.md +++ b/docs/simulators.md @@ -152,6 +152,40 @@ Now create the simulator program file `my-simulation.go`. // write your test code here } +### Generating Test Case Documentation + +The [package hivesim] provides automatic test case generation that can be used to compile all the +test cases that a given simulator runs into a markdown file. + +The documentation can be generated by executing the simulator binary with the environment variable +`HIVE_DOCS_MODE` set to "true". + +The document generator recurses into every test suite and test function that the simulator defines +and collects their names and descriptions, as defined in the `hivesim.Suite`, `hivesim.TestSpec` +or `hivesim.ClientTestSpec` objects. + +If the test object contains no description, it will not be listed in the documentation generated. + +A `TESTS.md` file that serves as an index document will be generated, inside of it a listing of +all the test suites will be included, along with the links to all test suite markdown files. + +For every test suite a `TESTS-.md` file will be generated, containing the listing of +all test cases included in the suite. + +The `Location` field of the `hivesim.Suite` can be used to specify a subdirectory where the +markdown file for this given suite will be placed. + +The `Category` field of `hivesim.TestSpec` or `hivesim.ClientTestSpec` can be used to generate +test categories in which the test cases will be grouped for readability purposes. + +The following environment variables can be used to configure document generation: +- `HIVE_DOCS_MODE`: Enable test case documentation generation (set to "true"). +- `HIVE_SIMULATOR_NAME`: Name of the simulator for which the documentation is being generated. +If unset, the path of the simulator executable will be used to parse the simulator's name. +- `HIVE_DOCS_OUTPUT_DIR`: Output root directory for all generated markdown files. +If unset, the current working directory will be used. + + ### Creating the Dockerfile The simulator needs to have a Dockerfile in order to run. From 4e84c23423d5e25e81543ecbf9a6e3dc8f135251 Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Mon, 27 Nov 2023 17:56:49 +0000 Subject: [PATCH 3/4] hivesim: docs: preserve suite/test order --- hivesim/docs.go | 67 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/hivesim/docs.go b/hivesim/docs.go index 0b0c4f64fe..c779aad224 100644 --- a/hivesim/docs.go +++ b/hivesim/docs.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/ethereum/hive/internal/simapi" @@ -54,7 +55,7 @@ func (tc *markdownTestCase) toMarkdown(simName string, suiteName string, depth i sb := strings.Builder{} // Print test case display name - sb.WriteString(fmt.Sprintf("%s %s\n\n", strings.Repeat("#", depth), tc.displayName())) + sb.WriteString(fmt.Sprintf("%s - %s\n\n", strings.Repeat("#", depth), tc.displayName())) // Print command-line to run sb.WriteString(fmt.Sprintf("%s Run\n\n", strings.Repeat("#", depth+1))) @@ -70,20 +71,6 @@ func (tc *markdownTestCase) toMarkdown(simName string, suiteName string, depth i return sb.String() } -func getCategories(testCases map[TestID]*markdownTestCase) []string { - categoryMap := make(map[string]struct{}) - categories := make([]string, 0) - for _, tc := range testCases { - if tc.toBePrinted() { - if _, ok := categoryMap[tc.Category]; !ok { - categories = append(categories, tc.Category) - categoryMap[tc.Category] = struct{}{} - } - } - } - return categories -} - type markdownSuite struct { simapi.TestRequest running bool @@ -113,6 +100,33 @@ func (s *markdownSuite) description() string { return formatDescription(s.Description) } +func (s *markdownSuite) testIDs() []TestID { + // Returns a sorted list of the test IDs + testIDs := make([]TestID, 0, len(s.tests)) + for testID := range s.tests { + testIDs = append(testIDs, testID) + } + sort.Slice(testIDs, func(i, j int) bool { + return testIDs[i] < testIDs[j] + }) + return testIDs +} + +func (s *markdownSuite) getCategories() []string { + categoryMap := make(map[string]struct{}) + categories := make([]string, 0) + for _, tcID := range s.testIDs() { + tc := s.tests[tcID] + if tc.toBePrinted() { + if _, ok := categoryMap[tc.Category]; !ok { + categories = append(categories, tc.Category) + categoryMap[tc.Category] = struct{}{} + } + } + } + return categories +} + func (s *markdownSuite) toMarkdown(simName string) (string, error) { headerBuilder := strings.Builder{} categoryBuilder := map[string]*strings.Builder{} @@ -127,7 +141,7 @@ func (s *markdownSuite) toMarkdown(simName string) (string, error) { headerBuilder.WriteString(fmt.Sprintf("```bash\n%s\n```\n\n", s.commandLine(simName))) headerBuilder.WriteString("\n\n") - categories := getCategories(s.tests) + categories := s.getCategories() tcDepth := 3 if len(categories) > 1 { @@ -142,7 +156,8 @@ func (s *markdownSuite) toMarkdown(simName string) (string, error) { headerBuilder.WriteString("## Test Cases\n\n") } - for _, tc := range s.tests { + for _, tcID := range s.testIDs() { + tc := s.tests[tcID] if !tc.toBePrinted() { continue } @@ -253,6 +268,18 @@ func (docs *docsCollector) AnyRunning() bool { return false } +func (docs *docsCollector) suiteIDs() []SuiteID { + // Returns a sorted list of the suite IDs + suiteIDs := make([]SuiteID, 0, len(docs.suites)) + for suiteID := range docs.suites { + suiteIDs = append(suiteIDs, suiteID) + } + sort.Slice(suiteIDs, func(i, j int) bool { + return suiteIDs[i] < suiteIDs[j] + }) + return suiteIDs +} + func (docs *docsCollector) StartSuite(suite *simapi.TestRequest, simlog string) (SuiteID, error) { // Create a new markdown suite. markdownSuite := &markdownSuite{ @@ -317,7 +344,8 @@ func (docs *docsCollector) generateIndex(simName string) (string, error) { headerBuilder := strings.Builder{} headerBuilder.WriteString(fmt.Sprintf("# Simulator `%s` Test Cases\n\n", simName)) headerBuilder.WriteString("## Test Suites\n\n") - for _, s := range docs.suites { + for _, sID := range docs.suiteIDs() { + s := docs.suites[sID] headerBuilder.WriteString(fmt.Sprintf("### - [%s](%s)\n", s.displayName(), s.mardownFilePath())) headerBuilder.WriteString(s.Description) headerBuilder.WriteString("\n\n") @@ -366,7 +394,8 @@ func (docs *docsCollector) genSimulatorMarkdownFiles(fw FileWriter) error { if err != nil { return err } - for _, s := range docs.suites { + for _, sID := range docs.suiteIDs() { + s := docs.suites[sID] if err := s.toMarkdownFile(fw, docs.simName); err != nil { return err } From f931b46287a8ebc7124ae33ab830b3d1322d475a Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 28 Nov 2023 20:22:22 +0000 Subject: [PATCH 4/4] hivesim/docs: cleanup --- hivesim/docs.go | 96 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/hivesim/docs.go b/hivesim/docs.go index c779aad224..4cdd9f1fe2 100644 --- a/hivesim/docs.go +++ b/hivesim/docs.go @@ -13,6 +13,7 @@ import ( "gopkg.in/inconshreveable/log15.v2" ) +// Converts a string to a valid markdown link. func toMarkdownLink(title string) string { removeChars := []string{":", "#", "'", "\"", "`", "*", "+", ",", ";"} title = strings.ReplaceAll(strings.ToLower(title), " ", "-") @@ -22,6 +23,7 @@ func toMarkdownLink(title string) string { return title } +// Formats the description string to be printed in the markdown file. func formatDescription(desc string) string { desc = dedent.Dedent(desc) // Replace single quotes with backticks, since backticks are used for markdown code blocks and @@ -30,16 +32,20 @@ func formatDescription(desc string) string { return desc } +// Represents a single test case to be printed in the markdown file. type markdownTestCase simapi.TestRequest +// Returns the command-line to run the test case. func (tc *markdownTestCase) commandLine(simName string, suiteName string) string { return fmt.Sprintf("./hive --client --sim %s --sim.limit \"%s/%s\"", simName, suiteName, tc.Name) } +// Returns true if the test case should be printed in the markdown file. func (tc *markdownTestCase) toBePrinted() bool { return tc.Description != "" } +// Returns the test case display name. func (tc *markdownTestCase) displayName() string { if tc.DisplayName != "" { return tc.DisplayName @@ -47,10 +53,14 @@ func (tc *markdownTestCase) displayName() string { return tc.Name } +// Returns the test case description. func (tc *markdownTestCase) description() string { return formatDescription(tc.Description) } +// Returns the test case markdown representation. +// Requires the simulation name and the suite name. +// The depth parameter is used to determine the number of '#' to use for the test case title. func (tc *markdownTestCase) toMarkdown(simName string, suiteName string, depth int) string { sb := strings.Builder{} @@ -71,12 +81,14 @@ func (tc *markdownTestCase) toMarkdown(simName string, suiteName string, depth i return sb.String() } +// Represents a test suite to be printed in the markdown file. type markdownSuite struct { simapi.TestRequest running bool tests map[TestID]*markdownTestCase } +// Returns true if the test suite should be printed in the markdown file. func (s *markdownSuite) displayName() string { if s.DisplayName != "" { return s.DisplayName @@ -84,6 +96,10 @@ func (s *markdownSuite) displayName() string { return fmt.Sprintf("`%s`", s.Name) } +// Returns the markdown file path for the test suite. +// If the location is set, it will be used as the directory path, and the filename will be +// `TESTS.md`. +// Otherwise, the filename will be `TESTS-.md`. func (s *markdownSuite) mardownFilePath() string { if s.Location != "" { return fmt.Sprintf("%s/TESTS.md", s.Location) @@ -92,16 +108,18 @@ func (s *markdownSuite) mardownFilePath() string { return fmt.Sprintf("TESTS-%s.md", title) } +// Returns the command-line to run the test suite. func (s *markdownSuite) commandLine(simName string) string { return fmt.Sprintf("./hive --client --sim %s --sim.limit \"%s/\"", simName, s.Name) } +// Returns true if the test suite should be printed in the markdown file. func (s *markdownSuite) description() string { return formatDescription(s.Description) } +// Returns a sorted list of the test IDs func (s *markdownSuite) testIDs() []TestID { - // Returns a sorted list of the test IDs testIDs := make([]TestID, 0, len(s.tests)) for testID := range s.tests { testIDs = append(testIDs, testID) @@ -112,6 +130,7 @@ func (s *markdownSuite) testIDs() []TestID { return testIDs } +// Returns a list of all the unique categories in all of the test cases. func (s *markdownSuite) getCategories() []string { categoryMap := make(map[string]struct{}) categories := make([]string, 0) @@ -127,6 +146,7 @@ func (s *markdownSuite) getCategories() []string { return categories } +// Returns the markdown representation of the test suite. func (s *markdownSuite) toMarkdown(simName string) (string, error) { headerBuilder := strings.Builder{} categoryBuilder := map[string]*strings.Builder{} @@ -190,6 +210,7 @@ func (s *markdownSuite) toMarkdown(simName string) (string, error) { return headerBuilder.String(), nil } +// Writes the markdown representation of the test suite to a file. func (s *markdownSuite) toMarkdownFile(fw FileWriter, simName string) error { // Create the file. file, err := fw.CreateWriter(s.mardownFilePath()) @@ -207,13 +228,16 @@ func (s *markdownSuite) toMarkdownFile(fw FileWriter, simName string) error { return err } -// Docs collector object +// Docs collector object: +// - Collects the test cases and test suites. +// - Generates markdown files. type docsCollector struct { simName string outputDir string suites map[SuiteID]*markdownSuite } +// Returns the simulator name from the path of the currently running binary. func simulatorNameFromBinaryPath() string { execPath, err := os.Executable() if err != nil { @@ -233,6 +257,7 @@ func simulatorNameFromBinaryPath() string { } // NewDocsCollector creates a new docs collector object. +// Tries to parse the simulator name and also the output directory from the environment variables. func NewDocsCollector() *docsCollector { docs := &docsCollector{ suites: make(map[SuiteID]*markdownSuite), @@ -268,6 +293,7 @@ func (docs *docsCollector) AnyRunning() bool { return false } +// Returns a sorted list of the suite IDs func (docs *docsCollector) suiteIDs() []SuiteID { // Returns a sorted list of the suite IDs suiteIDs := make([]SuiteID, 0, len(docs.suites)) @@ -280,6 +306,7 @@ func (docs *docsCollector) suiteIDs() []SuiteID { return suiteIDs } +// Starts a new test suite, and appends it to the suites map. func (docs *docsCollector) StartSuite(suite *simapi.TestRequest, simlog string) (SuiteID, error) { // Create a new markdown suite. markdownSuite := &markdownSuite{ @@ -295,6 +322,8 @@ func (docs *docsCollector) StartSuite(suite *simapi.TestRequest, simlog string) return suiteID, nil } +// Ends a test suite. If the suite does not exist, returns an error. +// If all suites are done, generates the markdown files. func (docs *docsCollector) EndSuite(testSuite SuiteID) error { suite, ok := docs.suites[testSuite] if !ok { @@ -310,6 +339,8 @@ func (docs *docsCollector) EndSuite(testSuite SuiteID) error { return nil } +// Starts a new test case, and appends it to the tests map in the test suite. +// If the suite does not exist, returns an error. func (docs *docsCollector) StartTest(testSuite SuiteID, test *simapi.TestRequest) (TestID, error) { // Create a new markdown test case. markdownTest := markdownTestCase(*test) @@ -325,13 +356,21 @@ func (docs *docsCollector) StartTest(testSuite SuiteID, test *simapi.TestRequest return testID, nil } +// Ends a test case. If the suite or the test case or suite do not exist, returns an error. func (docs *docsCollector) EndTest(testSuite SuiteID, test TestID, testResult TestResult) error { - // No-op in docs mode. + // Check if suite exists. + if _, ok := docs.suites[testSuite]; !ok { + return fmt.Errorf("test suite %d does not exist", testSuite) + } + // Check if test exists. + if _, ok := docs.suites[testSuite].tests[test]; !ok { + return fmt.Errorf("test %d does not exist", test) + } return nil } +// Return a generic "Client" client type. func (docs *docsCollector) ClientTypes() ([]*ClientDefinition, error) { - // Return a dummy "docs" client type. return []*ClientDefinition{ { Name: "Client", @@ -340,6 +379,7 @@ func (docs *docsCollector) ClientTypes() ([]*ClientDefinition, error) { }, nil } +// Generates the markdown index file that will point to the test suites' markdown files. func (docs *docsCollector) generateIndex(simName string) (string, error) { headerBuilder := strings.Builder{} headerBuilder.WriteString(fmt.Sprintf("# Simulator `%s` Test Cases\n\n", simName)) @@ -353,6 +393,7 @@ func (docs *docsCollector) generateIndex(simName string) (string, error) { return headerBuilder.String() + "\n", nil } +// Generates the markdown index file that will point to the test suites' markdown files. func (docs *docsCollector) generateIndexFile(fw FileWriter, simName string) error { markdownIndex, err := docs.generateIndex(simName) if err != nil { @@ -367,38 +408,43 @@ func (docs *docsCollector) generateIndexFile(fw FileWriter, simName string) erro return err } +// Generates the markdown files for all the test suites, including the index file. +func (docs *docsCollector) genSimulatorMarkdownFiles(fw FileWriter) error { + err := docs.generateIndexFile(fw, docs.simName) + if err != nil { + return err + } + for _, sID := range docs.suiteIDs() { + s := docs.suites[sID] + if err := s.toMarkdownFile(fw, docs.simName); err != nil { + return err + } + } + return nil +} + +// FileWriter interface. Used to create the markdown files. type FileWriter interface { CreateWriter(path string) (io.WriteCloser, error) } +// FileWriter implementation that writes to the filesystem. +// The basePath is the root directory where the files will be created. type fileWriter struct { - path string + basePath string } -var _ FileWriter = (*fileWriter)(nil) - -func (fw *fileWriter) CreateWriter(path string) (io.WriteCloser, error) { - filePath := filepath.FromSlash(fmt.Sprintf("%s/%s", fw.path, path)) +// Creates a new file writer. The path starts from the base directory that is the fileWriter. +// Subdirectories will be created if they do not exist. +func (fw fileWriter) CreateWriter(path string) (io.WriteCloser, error) { + filePath := filepath.FromSlash(fmt.Sprintf("%s/%s", filepath.ToSlash(fw.basePath), path)) if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { return nil, err } return os.Create(filePath) } -func NewFileWriter(path string) FileWriter { - return &fileWriter{path} -} - -func (docs *docsCollector) genSimulatorMarkdownFiles(fw FileWriter) error { - err := docs.generateIndexFile(fw, docs.simName) - if err != nil { - return err - } - for _, sID := range docs.suiteIDs() { - s := docs.suites[sID] - if err := s.toMarkdownFile(fw, docs.simName); err != nil { - return err - } - } - return nil +// NewFileWriter creates a new file writer with the given base path. +func NewFileWriter(basePath string) FileWriter { + return fileWriter{basePath} }