func TestParseCommandLineOptions(t *testing.T) { t.Log("Parse complicated command") { expectedWords := []string{"/bin/sh", "-c", `echo "my complicated command" | tee log | cat > log2`} words, err := shellquote.Split("/bin/sh -c 'echo \"my complicated command\" | tee log | cat > log2'") if err != nil { t.Fatalf("Expected (no error), actual(%v)", err) } if len(words) != len(expectedWords) { t.Fatalf("Expected (%d), actual(%d)", len(expectedWords), len(words)) } for i := 0; i < len(expectedWords); i++ { exceptedWord := expectedWords[i] word := words[i] if word != exceptedWord { t.Fatalf("Expected (%s), actual(%s)", exceptedWord, word) } } } t.Log("Parse invalid command") { _, err := shellquote.Split("/bin/sh -c 'echo") if err == nil { t.Fatalf("Expected (error), actual(%v)", err) } } }
func runGradleTask(gradleTool, buildFile, tasks, options string) error { optionSlice, err := shellquote.Split(options) if err != nil { return err } taskSlice, err := shellquote.Split(tasks) if err != nil { return err } cmdSlice := []string{gradleTool, "--build-file", buildFile} cmdSlice = append(cmdSlice, taskSlice...) cmdSlice = append(cmdSlice, optionSlice...) log.Done("$ %s", cmdex.PrintableCommandArgs(false, cmdSlice)) fmt.Println() cmd, err := cmdex.NewCommandFromSlice(cmdSlice) if err != nil { return fmt.Errorf("failed to create command, error: %s", err) } cmd.SetStdout(os.Stdout) cmd.SetStderr(os.Stderr) return cmd.Run() }
func _spawn(command string) (*ExpectSubprocess, error) { wrapper := new(ExpectSubprocess) wrapper.outputBuffer = nil splitArgs, err := shell.Split(command) if err != nil { return nil, err } numArguments := len(splitArgs) - 1 if numArguments < 0 { return nil, errors.New("gexpect: No command given to spawn") } path, err := exec.LookPath(splitArgs[0]) if err != nil { return nil, err } if numArguments >= 1 { wrapper.Cmd = exec.Command(path, splitArgs[1:]...) } else { wrapper.Cmd = exec.Command(path) } wrapper.buf = new(buffer) return wrapper, nil }
func splitShellInput(input string) []string { inputs, err := shellquote.Split(input) if err != nil { inputs = strings.Split(input, " ") } return inputs }
func expandAlias(args *Args) { cmd := args.Command expandedCmd, err := git.Config(fmt.Sprintf("alias.%s", cmd)) if err == nil && expandedCmd != "" { words, err := shellquote.Split(expandedCmd) if err != nil { args.Command = words[0] args.PrependParams(words[1:]...) } } }
// TransformConfig is used to transform a string from a config file into a // sliced value, using shlex. func (s *ShlexSlice) TransformConfig(raw reflect.Value) error { if !raw.IsValid() { return fmt.Errorf("must be a string, was undefined") } var err error switch value := raw.Interface().(type) { case string: s.original = value s.parsed, err = shlex.Split(value) if err != nil { return fmt.Errorf("failed to parse command %q: %s", value, err) } default: return fmt.Errorf("must be a string, not %T", value) } return nil }
func runRktAndCheckRegexOutput(t *testing.T, rktCmd, match string) error { re, err := regexp.Compile(match) if err != nil { t.Fatalf("error compiling regex %q: %v", match, err) } args, err := shellquote.Split(rktCmd) if err != nil { t.Fatalf("error splitting cmd %q: %v", rktCmd, err) } path, err := exec.LookPath(args[0]) cmd := exec.Command(path, args[1:]...) out, err := cmd.CombinedOutput() result := re.MatchString(string(out)) if !result { t.Fatalf("%q regex must be found\nOutput: %q", match, string(out)) } return err }
func main() { configs := createConfigsModelFromEnvs() fmt.Println() configs.print() if err := configs.validate(); err != nil { log.Error("Issue with input: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } // Custom Options resultLogPth := filepath.Join(configs.DeployDir, "TestResult.xml") customOptions := []string{"--result", resultLogPth} if configs.CustomOptions != "" { options, err := shellquote.Split(configs.CustomOptions) if err != nil { log.Error("Failed to split params (%s), error: %s", configs.CustomOptions, err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } customOptions = append(customOptions, options...) } // --- // // build fmt.Println() log.Info("Runing all nunit test projects in solution: %s", configs.XamarinSolution) builder, err := builder.New(configs.XamarinSolution, []constants.ProjectType{}, false) if err != nil { log.Error("Failed to create xamarin builder, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } prepareCallback := func(solutionName string, projectName string, projectType constants.ProjectType, command *tools.Editable) { if projectType == constants.ProjectTypeNunitTest { (*command).SetCustomOptions(customOptions...) } } callback := func(solutionName string, projectName string, projectType constants.ProjectType, commandStr string, alreadyPerformed bool) { fmt.Println() if projectName == "" { log.Info("Building solution: %s", solutionName) } else { if projectType == constants.ProjectTypeNunitTest { log.Info("Building test project: %s", projectName) } else { log.Info("Building project: %s", projectName) } } log.Done("$ %s", commandStr) if alreadyPerformed { log.Warn("build command already performed, skipping...") } fmt.Println() } warnings, err := builder.BuildAllNunitTestProjects(configs.XamarinConfiguration, configs.XamarinPlatform, prepareCallback, callback) resultLog, logErr := testResultLogContent(resultLogPth) if logErr != nil { log.Warn("Failed to read test result, error: %s", logErr) } for _, warning := range warnings { log.Warn(warning) } if err != nil { log.Error("Test run failed, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } if resultLog != "" { if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", resultLog); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", err) } } os.Exit(1) } if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "succeeded"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } if resultLog != "" { if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", resultLog); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", err) } } }
func main() { configs := createConfigsModelFromEnvs() fmt.Println() configs.print() if err := configs.validate(); err != nil { log.Error("Issue with input: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } // // build fmt.Println() log.Info("Building all iOS Xamarin UITest and Referred Projects in solution: %s", configs.XamarinSolution) builder, err := builder.New(configs.XamarinSolution, []constants.ProjectType{constants.ProjectTypeIOS}, false) if err != nil { log.Error("Failed to create xamarin builder, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } callback := func(solutionName string, projectName string, projectType constants.ProjectType, commandStr string, alreadyPerformed bool) { fmt.Println() if projectType == constants.ProjectTypeXamarinUITest { log.Info("Building test project: %s", projectName) } else { log.Info("Building project: %s", projectName) } log.Done("$ %s", commandStr) if alreadyPerformed { log.Warn("build command already performed, skipping...") } fmt.Println() } warnings, err := builder.BuildAllXamarinUITestAndReferredProjects(configs.XamarinConfiguration, configs.XamarinPlatform, nil, callback) for _, warning := range warnings { log.Warn(warning) } if err != nil { log.Error("Build failed, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } projectOutputMap, err := builder.CollectProjectOutputs(configs.XamarinConfiguration, configs.XamarinPlatform) if err != nil { log.Error("Failed to collect project outputs, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } testProjectOutputMap, warnings, err := builder.CollectXamarinUITestProjectOutputs(configs.XamarinConfiguration, configs.XamarinPlatform) for _, warning := range warnings { log.Warn(warning) } if err != nil { log.Error("Failed to collect test project output, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } // --- // // Test Cloud submit solutionDir := filepath.Dir(configs.XamarinSolution) pattern := filepath.Join(solutionDir, "packages/Xamarin.UITest.*/tools/test-cloud.exe") testClouds, err := filepath.Glob(pattern) if err != nil { log.Error("Failed to find test-cloud.exe path with pattern (%s), error: %s", pattern, err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } if len(testClouds) == 0 { if err != nil { log.Error("No test-cloud.exe found path with pattern (%s)", pattern) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } } testCloud, err := testcloud.NewModel(testClouds[0]) if err != nil { log.Error("Failed to create test cloud model, error: %s", err) os.Exit(1) } testCloud.SetAPIKey(configs.APIKey) testCloud.SetUser(configs.User) testCloud.SetDevices(configs.Devices) testCloud.SetIsAsyncJSON(configs.IsAsync == "yes") testCloud.SetSeries(configs.Series) // If test cloud runs in asnyc mode test result will not be saved into file resultLogPth := filepath.Join(configs.DeployDir, "TestResult.xml") if configs.IsAsync != "yes" { testCloud.SetNunitXMLPth(resultLogPth) } // Parallelization if configs.Parallelization != "none" { parallelization, err := testcloud.ParseParallelization(configs.Parallelization) if err != nil { log.Error("Failed to parse parallelization, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } testCloud.SetParallelization(parallelization) } // --- // Custom Options if configs.CustomOptions != "" { options, err := shellquote.Split(configs.CustomOptions) if err != nil { log.Error("Failed to split params (%s), error: %s", configs.CustomOptions, err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } os.Exit(1) } testCloud.SetCustomOptions(options...) } // --- // Artifacts resultLog := "" for testProjectName, testProjectOutput := range testProjectOutputMap { if len(testProjectOutput.ReferredProjectNames) == 0 { log.Warn("Test project (%s) does not refers to any project, skipping...", testProjectName) continue } for _, projectName := range testProjectOutput.ReferredProjectNames { projectOutput, ok := projectOutputMap[projectName] if !ok { continue } ipaPth := "" dsymPth := "" for _, output := range projectOutput.Outputs { if output.OutputType == constants.OutputTypeIPA { ipaPth = output.Pth } if output.OutputType == constants.OutputTypeDSYM { dsymPth = output.Pth } } if ipaPth == "" { log.Warn("No ipa generated for project: %s", projectName) } if dsymPth == "" { log.Warn("No dsym generated for project: %s", projectName) } // Submit fmt.Println() log.Info("Testing (%s) against (%s)", testProjectName, projectName) log.Detail("test dll: %s", testProjectOutput.Output.Pth) log.Detail("ipa: %s", ipaPth) log.Detail("dsym: %s", dsymPth) testCloud.SetAssemblyDir(filepath.Dir(testProjectOutput.Output.Pth)) testCloud.SetIPAPth(ipaPth) testCloud.SetDSYMPth(dsymPth) fmt.Println() log.Info("Submitting:") log.Done("$ %s", testCloud.PrintableCommand()) lines := []string{} callback := func(line string) { log.Detail(line) lines = append(lines, line) } err := testCloud.Submit(callback) // If test cloud runs in asnyc mode test result will not be saved into file if configs.IsAsync != "yes" { testLog, logErr := testResultLogContent(resultLogPth) if logErr != nil { log.Warn("Failed to read test result, error: %s", logErr) } resultLog = testLog } if err != nil { log.Error("Submit failed, error: %s", err) if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } if resultLog != "" { if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", resultLog); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", err) } } os.Exit(1) } // --- if configs.IsAsync == "yes" { fmt.Println() log.Info("Preocessing json result:") jsonLine := "" for _, line := range lines { if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") { jsonLine = line } } if jsonLine != "" { var result JSONResultModel if err := json.Unmarshal([]byte(jsonLine), &result); err != nil { log.Error("Failed to unmarshal result, error: %s", err) } else { for _, errorMsg := range result.ErrorMessages { log.Error(errorMsg) } if len(result.ErrorMessages) > 0 { if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "failed"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } if resultLog != "" { if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", resultLog); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", err) } } os.Exit(1) } if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_TO_RUN_ID", result.TestRunID); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_TO_RUN_ID", err) } log.Done("TestRunId (%s) is available in (%s) environment variable", result.TestRunID, "BITRISE_XAMARIN_TEST_TO_RUN_ID") } } } } } // --- if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_RESULT", "succeeded"); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_RESULT", err) } if resultLog != "" { if err := exportEnvironmentWithEnvman("BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", resultLog); err != nil { log.Warn("Failed to export environment: %s, error: %s", "BITRISE_XAMARIN_TEST_FULL_RESULTS_TEXT", err) } } }
func runTest(buildTestParams models.XcodeBuildTestParamsModel, outputTool, xcprettyOptions string, isAutomaticRetryOnReason, isRetryOnFail bool) (string, int, error) { handleTestError := func(fullOutputStr string, exitCode int, testError error) (string, int, error) { // // Automatic retry for _, retryReasonPattern := range automaticRetryReasonPatterns { if isStringFoundInOutput(retryReasonPattern, fullOutputStr) { log.Warn("Automatic retry reason found in log: %s", retryReasonPattern) if isAutomaticRetryOnReason { log.Detail("isAutomaticRetryOnReason=true - retrying...") return runTest(buildTestParams, outputTool, xcprettyOptions, false, false) } log.Error("isAutomaticRetryOnReason=false, no more retry, stopping the test!") return fullOutputStr, exitCode, testError } } // // Retry on fail if isRetryOnFail { log.Warn("Test run failed") log.Detail("isRetryOnFail=true - retrying...") return runTest(buildTestParams, outputTool, xcprettyOptions, false, false) } return fullOutputStr, exitCode, testError } buildParams := buildTestParams.BuildParams xcodebuildArgs := []string{buildParams.Action, buildParams.ProjectPath, "-scheme", buildParams.Scheme} if buildTestParams.CleanBuild { xcodebuildArgs = append(xcodebuildArgs, "clean") } // the 'build' argument is required *before* the 'test' arg, to prevent // the Xcode bug described in the README, which causes: // 'iPhoneSimulator: Timed out waiting 120 seconds for simulator to boot, current state is 1.' // in case the compilation takes a long time. // Related Radar link: https://openradar.appspot.com/22413115 // Demonstration project: https://github.com/bitrise-io/simulator-launch-timeout-includes-build-time // for builds < 120 seconds or fixed Xcode versions, one should // have the possibility of opting out, because the explicit build arg // leads the project to be compiled twice and increase the duration // Related issue link: https://github.com/bitrise-io/steps-xcode-test/issues/55 if buildTestParams.BuildBeforeTest { xcodebuildArgs = append(xcodebuildArgs, "build") } xcodebuildArgs = append(xcodebuildArgs, "test", "-destination", buildParams.DeviceDestination) if buildTestParams.GenerateCodeCoverage { xcodebuildArgs = append(xcodebuildArgs, "GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES") xcodebuildArgs = append(xcodebuildArgs, "GCC_GENERATE_TEST_COVERAGE_FILES=YES") } if buildTestParams.AdditionalOptions != "" { options, err := shellquote.Split(buildTestParams.AdditionalOptions) if err != nil { return "", 1, fmt.Errorf("failed to parse additional options (%s), error: %s", buildTestParams.AdditionalOptions, err) } xcodebuildArgs = append(xcodebuildArgs, options...) } xcprettyArgs := []string{} if xcprettyOptions != "" { options, err := shellquote.Split(xcprettyOptions) if err != nil { return "", 1, fmt.Errorf("failed to parse additional options (%s), error: %s", xcprettyOptions, err) } // get and delete the xcpretty output file, if exists xcprettyOutputFilePath := "" isNextOptOutputPth := false for _, aOpt := range options { if isNextOptOutputPth { xcprettyOutputFilePath = aOpt break } if aOpt == "--output" { isNextOptOutputPth = true continue } } if xcprettyOutputFilePath != "" { if isExist, err := pathutil.IsPathExists(xcprettyOutputFilePath); err != nil { log.Error("Failed to check xcpretty output file status (path: %s), error: %s", xcprettyOutputFilePath, err) } else if isExist { log.Warn("=> Deleting existing xcpretty output: %s", xcprettyOutputFilePath) if err := os.Remove(xcprettyOutputFilePath); err != nil { log.Error("Failed to delete xcpretty output file (path: %s), error: %s", xcprettyOutputFilePath, err) } } } // xcprettyArgs = append(xcprettyArgs, options...) } log.Info("Running the tests...") var rawOutput string var err error var exit int if outputTool == "xcpretty" { rawOutput, exit, err = runPrettyXcodeBuildCmd(true, xcprettyArgs, xcodebuildArgs) } else { rawOutput, exit, err = runXcodeBuildCmd(true, xcodebuildArgs...) } if err != nil { return handleTestError(rawOutput, exit, err) } return rawOutput, exit, nil }
func main() { if err := globalFlags.Parse(os.Args[1:]); err != nil { Fatalln(err) } if verbose { printGlobals() } switch strings.ToLower(flagDecoration) { case "none": decoration = DecorationNone case "plain": decoration = DecorationPlain case "fancy": decoration = DecorationFancy default: Fatalln(fmt.Sprintf("Invalid decoration %s. Choices: none, plain, fancy.", flagDecoration)) } if flagConf == "" { reflex, err := NewReflex(globalConfig, globalFlags.Args()) if err != nil { Fatalln(err) } if verbose { reflex.PrintInfo("commandline") } reflexes = append(reflexes, reflex) if flagSequential { Fatalln("Cannot set --sequential without --config (because you cannot specify multiple commands).") } } else { if anyNonGlobalsRegistered() { Fatalln("Cannot set other flags along with --config other than --sequential, --verbose, and --decoration.") } // Now open the configuration file. // As a special case we read the config from stdin if --config is set to "-" var config io.ReadCloser if flagConf == "-" { config = os.Stdin } else { configFile, err := os.Open(flagConf) if err != nil { Fatalln(err) } config = configFile } scanner := bufio.NewScanner(config) lineNo := 0 for scanner.Scan() { lineNo++ errorMsg := fmt.Sprintf("Error on line %d of %s:", lineNo, flagConf) config := &Config{} flags := flag.NewFlagSet("", flag.ContinueOnError) registerFlags(flags, config) parts, err := shellquote.Split(scanner.Text()) if err != nil { Fatalln(errorMsg, err) } // Skip empty lines and comments (lines starting with #). if len(parts) == 0 || strings.HasPrefix(parts[0], "#") { continue } if err := flags.Parse(parts); err != nil { Fatalln(errorMsg, err) } reflex, err := NewReflex(config, flags.Args()) if err != nil { Fatalln(errorMsg, err) } if verbose { reflex.PrintInfo(fmt.Sprintf("%s, line %d", flagConf, lineNo)) } reflexes = append(reflexes, reflex) } if err := scanner.Err(); err != nil { Fatalln(err) } config.Close() } // Catch ctrl-c and make sure to kill off children. signals := make(chan os.Signal, 1) signal.Notify(signals, os.Interrupt) signal.Notify(signals, os.Signal(syscall.SIGTERM)) go func() { s := <-signals reason := fmt.Sprintf("Interrupted (%s). Cleaning up children...", s) cleanup(reason) }() defer cleanup("Cleaning up.") watcher, err := fsnotify.NewWatcher() if err != nil { Fatalln(err) } defer watcher.Close() rawChanges := make(chan string) allRawChanges := make([]chan<- string, len(reflexes)) done := make(chan error) for i, reflex := range reflexes { allRawChanges[i] = reflex.rawChanges } go watch(".", watcher, rawChanges, done) go broadcast(rawChanges, allRawChanges) go printOutput(stdout, os.Stdout) for _, reflex := range reflexes { go filterMatching(reflex.rawChanges, reflex.filtered, reflex) go batch(reflex.filtered, reflex.batched, reflex) go runEach(reflex.batched, reflex) if reflex.startService { // Easy hack to kick off the initial start. infoPrintln(reflex.id, "Starting service") runCommand(reflex, "", stdout) } } Fatalln(<-done) }